mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 02:53:31 +00:00
Merge pull request #68 from xarmian/phase-13/cloud-infrastructure
feat: Cloud infrastructure & scaling (PHASE-13)
This commit is contained in:
+93
-11
@@ -7,7 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -30,7 +30,9 @@ import (
|
||||
"regexp"
|
||||
|
||||
"github.com/xarmian/pad/internal/email"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/xarmian/pad/internal/events"
|
||||
"github.com/xarmian/pad/internal/logging"
|
||||
"github.com/xarmian/pad/internal/models"
|
||||
"github.com/xarmian/pad/internal/server"
|
||||
"github.com/xarmian/pad/internal/store"
|
||||
@@ -162,6 +164,17 @@ func serveCmd() *cobra.Command {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg := getConfig()
|
||||
|
||||
// Initialize structured logging
|
||||
logLevel := os.Getenv("PAD_LOG_LEVEL")
|
||||
if logLevel == "" {
|
||||
logLevel = "info"
|
||||
}
|
||||
logFormat := os.Getenv("PAD_LOG_FORMAT")
|
||||
if logFormat == "" {
|
||||
logFormat = "text"
|
||||
}
|
||||
logging.Setup(logLevel, logFormat)
|
||||
|
||||
if cmd.Flags().Changed("host") {
|
||||
cfg.Host = host
|
||||
}
|
||||
@@ -169,23 +182,40 @@ func serveCmd() *cobra.Command {
|
||||
cfg.Port = port
|
||||
}
|
||||
|
||||
s, err := store.New(cfg.DBPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open database: %w", err)
|
||||
// Open database (SQLite default, PostgreSQL via PAD_DB_DRIVER)
|
||||
var s *store.Store
|
||||
var err error
|
||||
dbDriver := os.Getenv("PAD_DB_DRIVER")
|
||||
if dbDriver == "postgres" {
|
||||
pgURL := os.Getenv("PAD_DATABASE_URL")
|
||||
if pgURL == "" {
|
||||
return fmt.Errorf("PAD_DATABASE_URL is required when PAD_DB_DRIVER=postgres")
|
||||
}
|
||||
s, err = store.NewPostgres(pgURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open postgres: %w", err)
|
||||
}
|
||||
slog.Info("Database using PostgreSQL")
|
||||
} else {
|
||||
s, err = store.New(cfg.DBPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
slog.Info("Database using SQLite", "path", cfg.DBPath)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
// Auto-upgrade: ensure all default collections exist in every workspace.
|
||||
// This is safe because SeedDefaultCollections skips collections that already exist.
|
||||
if workspaces, err := s.ListWorkspaces(); err == nil {
|
||||
log.Printf("Auto-upgrade: checking %d workspace(s) for missing default collections", len(workspaces))
|
||||
slog.Info("auto-upgrade: checking workspaces for missing default collections", "count", len(workspaces))
|
||||
for _, ws := range workspaces {
|
||||
if err := s.SeedDefaultCollections(ws.ID); err != nil {
|
||||
log.Printf("Warning: failed to seed defaults for workspace %s: %v", ws.Slug, err)
|
||||
slog.Warn("failed to seed defaults for workspace", "workspace", ws.Slug, "error", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("Warning: failed to list workspaces for auto-upgrade: %v", err)
|
||||
slog.Warn("failed to list workspaces for auto-upgrade", "error", err)
|
||||
}
|
||||
|
||||
srv := server.New(s)
|
||||
@@ -195,7 +225,23 @@ func serveCmd() *cobra.Command {
|
||||
srv.SetSecureCookies(cfg.SecureCookies)
|
||||
|
||||
// Attach event bus for real-time SSE
|
||||
srv.SetEventBus(events.New())
|
||||
var eventBus events.EventBus
|
||||
if redisURL := os.Getenv("PAD_REDIS_URL"); redisURL != "" {
|
||||
opts, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid PAD_REDIS_URL: %w", err)
|
||||
}
|
||||
rc := redis.NewClient(opts)
|
||||
if err := rc.Ping(context.Background()).Err(); err != nil {
|
||||
return fmt.Errorf("redis connection failed: %w", err)
|
||||
}
|
||||
eventBus = events.NewRedisBus(rc)
|
||||
slog.Info("Event bus using Redis pub/sub", "addr", opts.Addr, "db", opts.DB)
|
||||
} else {
|
||||
eventBus = events.New()
|
||||
slog.Info("Event bus using in-memory (single instance)")
|
||||
}
|
||||
srv.SetEventBus(eventBus)
|
||||
|
||||
// Attach webhook dispatcher for outgoing notifications
|
||||
srv.SetWebhookDispatcher(webhooks.NewDispatcher(s))
|
||||
@@ -211,7 +257,7 @@ func serveCmd() *cobra.Command {
|
||||
fromName = "Pad"
|
||||
}
|
||||
srv.SetEmailSender(email.NewSender(cfg.MailerooAPIKey, fromAddr, fromName, cfg.BaseURL()))
|
||||
log.Println("Email sending enabled via Maileroo (env)")
|
||||
slog.Info("Email sending enabled via Maileroo (env)")
|
||||
}
|
||||
// Platform settings can override or provide email config
|
||||
srv.InitEmailFromSettings()
|
||||
@@ -221,11 +267,47 @@ func serveCmd() *cobra.Command {
|
||||
if err == nil {
|
||||
if entries, err := fs.ReadDir(webFS, "."); err == nil && len(entries) > 0 {
|
||||
srv.SetWebUI(webFS)
|
||||
log.Println("Serving embedded web UI")
|
||||
slog.Info("Serving embedded web UI")
|
||||
}
|
||||
}
|
||||
|
||||
return srv.ListenAndServe(cfg.Addr())
|
||||
// Graceful shutdown: listen for SIGINT/SIGTERM
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Start server in a goroutine
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- srv.ListenAndServe(cfg.Addr())
|
||||
}()
|
||||
|
||||
// Wait for signal or server error
|
||||
select {
|
||||
case err := <-errCh:
|
||||
// Server failed to start or crashed
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
// Received shutdown signal
|
||||
slog.Info("Shutting down server (30s grace period)...")
|
||||
stop() // Reset signal handling so a second signal force-kills
|
||||
|
||||
// Close event bus first — this terminates SSE handler
|
||||
// goroutines so http.Server.Shutdown won't block on them.
|
||||
if eventBus != nil {
|
||||
eventBus.Close()
|
||||
slog.Info("Event bus closed")
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("HTTP server shutdown error", "error", err)
|
||||
}
|
||||
|
||||
slog.Info("Server stopped")
|
||||
return nil
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -3,26 +3,39 @@ module github.com/xarmian/pad
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/fatih/color v1.19.0
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/time v0.15.0
|
||||
modernc.org/sqlite v1.47.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/go-chi/cors v1.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.47.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
@@ -11,10 +20,24 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
@@ -24,7 +47,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -35,26 +61,65 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
+44
-12
@@ -1,7 +1,7 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -47,29 +47,49 @@ type Event struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// EventBus is the interface for pub/sub event distribution.
|
||||
// Implementations include MemoryBus (in-process) and RedisBus (cross-instance).
|
||||
type EventBus interface {
|
||||
// Subscribe registers a new subscriber for the given workspace.
|
||||
// Returns a buffered channel that will receive events for that workspace.
|
||||
Subscribe(workspaceID string) chan Event
|
||||
|
||||
// Unsubscribe removes a subscriber and closes its channel.
|
||||
Unsubscribe(ch chan Event)
|
||||
|
||||
// Publish sends an event to all subscribers for the event's workspace.
|
||||
Publish(event Event)
|
||||
|
||||
// Close shuts down the event bus and cleans up resources.
|
||||
Close()
|
||||
|
||||
// SubscriberCount returns the number of active local subscribers.
|
||||
SubscriberCount() int
|
||||
}
|
||||
|
||||
// subscriber wraps a channel with its workspace filter.
|
||||
type subscriber struct {
|
||||
ch chan Event
|
||||
workspaceID string
|
||||
}
|
||||
|
||||
// Bus is an in-process pub/sub event bus that fans out events
|
||||
// to all subscribers for a given workspace.
|
||||
type Bus struct {
|
||||
// MemoryBus is an in-process pub/sub event bus that fans out events
|
||||
// to all subscribers for a given workspace. Suitable for single-instance deployments.
|
||||
type MemoryBus struct {
|
||||
mu sync.RWMutex
|
||||
subscribers map[chan Event]*subscriber
|
||||
}
|
||||
|
||||
// New creates a new EventBus.
|
||||
func New() *Bus {
|
||||
return &Bus{
|
||||
// New creates a new in-memory EventBus.
|
||||
func New() *MemoryBus {
|
||||
return &MemoryBus{
|
||||
subscribers: make(map[chan Event]*subscriber),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers a new subscriber for the given workspace.
|
||||
// Returns a buffered channel that will receive events for that workspace.
|
||||
func (b *Bus) Subscribe(workspaceID string) chan Event {
|
||||
func (b *MemoryBus) Subscribe(workspaceID string) chan Event {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
@@ -82,7 +102,7 @@ func (b *Bus) Subscribe(workspaceID string) chan Event {
|
||||
}
|
||||
|
||||
// Unsubscribe removes a subscriber and closes its channel.
|
||||
func (b *Bus) Unsubscribe(ch chan Event) {
|
||||
func (b *MemoryBus) Unsubscribe(ch chan Event) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
@@ -95,7 +115,7 @@ func (b *Bus) Unsubscribe(ch chan Event) {
|
||||
// Publish sends an event to all subscribers for the event's workspace.
|
||||
// Non-blocking: if a subscriber's channel is full, the event is dropped
|
||||
// and a warning is logged.
|
||||
func (b *Bus) Publish(event Event) {
|
||||
func (b *MemoryBus) Publish(event Event) {
|
||||
if event.Timestamp == 0 {
|
||||
event.Timestamp = time.Now().UnixMilli()
|
||||
}
|
||||
@@ -110,13 +130,25 @@ func (b *Bus) Publish(event Event) {
|
||||
select {
|
||||
case sub.ch <- event:
|
||||
default:
|
||||
log.Printf("events: dropping event %s for slow subscriber (workspace=%s)", event.Type, event.WorkspaceID)
|
||||
slog.Warn("dropping event for slow subscriber", "type", event.Type, "workspace", event.WorkspaceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down the event bus by closing all subscriber channels.
|
||||
// SSE handler goroutines will see the channel close and exit cleanly.
|
||||
func (b *MemoryBus) Close() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
for ch := range b.subscribers {
|
||||
delete(b.subscribers, ch)
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// SubscriberCount returns the number of active subscribers (for testing/debugging).
|
||||
func (b *Bus) SubscriberCount() int {
|
||||
func (b *MemoryBus) SubscriberCount() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return len(b.subscribers)
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
// redisChannelPrefix is prepended to workspace IDs for Redis pub/sub channels.
|
||||
redisChannelPrefix = "pad:events:"
|
||||
|
||||
// reconnectDelay is how long to wait before retrying a failed Redis subscription.
|
||||
reconnectDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
// RedisBus distributes events across multiple Pad instances via Redis pub/sub.
|
||||
// Each instance subscribes to Redis channels for its locally-connected SSE clients,
|
||||
// and publishes events to Redis so all instances see them.
|
||||
type RedisBus struct {
|
||||
client *redis.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
subscribers map[chan Event]*subscriber
|
||||
|
||||
// Track which workspace channels we're subscribed to in Redis,
|
||||
// so we subscribe/unsubscribe as local SSE clients come and go.
|
||||
wsCounts map[string]int // workspace → local subscriber count
|
||||
wsSubs map[string]*redisSub // workspace → active Redis subscription
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// redisSub tracks an active Redis subscription for a workspace.
|
||||
type redisSub struct {
|
||||
pubsub *redis.PubSub
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewRedisBus creates a new Redis-backed EventBus.
|
||||
// The provided redis.Client should already be configured and connected.
|
||||
func NewRedisBus(client *redis.Client) *RedisBus {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &RedisBus{
|
||||
client: client,
|
||||
subscribers: make(map[chan Event]*subscriber),
|
||||
wsCounts: make(map[string]int),
|
||||
wsSubs: make(map[string]*redisSub),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers a local subscriber for the given workspace.
|
||||
// Starts a Redis subscription for the workspace if this is the first local subscriber.
|
||||
func (b *RedisBus) Subscribe(workspaceID string) chan Event {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
ch := make(chan Event, 64)
|
||||
b.subscribers[ch] = &subscriber{
|
||||
ch: ch,
|
||||
workspaceID: workspaceID,
|
||||
}
|
||||
|
||||
b.wsCounts[workspaceID]++
|
||||
if b.wsCounts[workspaceID] == 1 {
|
||||
// First local subscriber for this workspace — subscribe to Redis channel
|
||||
b.startRedisSubscription(workspaceID)
|
||||
}
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
// Unsubscribe removes a local subscriber and closes its channel.
|
||||
// Cancels the Redis subscription if this was the last local subscriber for the workspace.
|
||||
func (b *RedisBus) Unsubscribe(ch chan Event) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
sub, ok := b.subscribers[ch]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
delete(b.subscribers, ch)
|
||||
close(ch)
|
||||
|
||||
wsID := sub.workspaceID
|
||||
b.wsCounts[wsID]--
|
||||
if b.wsCounts[wsID] <= 0 {
|
||||
delete(b.wsCounts, wsID)
|
||||
b.stopRedisSubscription(wsID)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish sends an event to Redis, which distributes it to all instances.
|
||||
func (b *RedisBus) Publish(event Event) {
|
||||
if event.Timestamp == 0 {
|
||||
event.Timestamp = time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
slog.Error("failed to marshal event for Redis", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
channel := redisChannelPrefix + event.WorkspaceID
|
||||
if err := b.client.Publish(b.ctx, channel, data).Err(); err != nil {
|
||||
slog.Error("failed to publish event to Redis", "channel", channel, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down all Redis subscriptions and closes local subscriber channels.
|
||||
func (b *RedisBus) Close() {
|
||||
b.cancel() // signal all subscription goroutines to stop
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
for wsID, sub := range b.wsSubs {
|
||||
sub.cancel()
|
||||
sub.pubsub.Close()
|
||||
delete(b.wsSubs, wsID)
|
||||
}
|
||||
|
||||
for ch := range b.subscribers {
|
||||
delete(b.subscribers, ch)
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// SubscriberCount returns the number of active local subscribers.
|
||||
func (b *RedisBus) SubscriberCount() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return len(b.subscribers)
|
||||
}
|
||||
|
||||
// startRedisSubscription begins listening on a Redis channel for a workspace.
|
||||
// Must be called with b.mu held.
|
||||
func (b *RedisBus) startRedisSubscription(workspaceID string) {
|
||||
channel := redisChannelPrefix + workspaceID
|
||||
pubsub := b.client.Subscribe(b.ctx, channel)
|
||||
|
||||
subCtx, subCancel := context.WithCancel(b.ctx)
|
||||
b.wsSubs[workspaceID] = &redisSub{
|
||||
pubsub: pubsub,
|
||||
cancel: subCancel,
|
||||
}
|
||||
|
||||
go b.receiveMessages(subCtx, pubsub, workspaceID)
|
||||
}
|
||||
|
||||
// stopRedisSubscription cancels and cleans up the Redis subscription for a workspace.
|
||||
// Must be called with b.mu held.
|
||||
func (b *RedisBus) stopRedisSubscription(workspaceID string) {
|
||||
sub, ok := b.wsSubs[workspaceID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sub.cancel()
|
||||
sub.pubsub.Close()
|
||||
delete(b.wsSubs, workspaceID)
|
||||
}
|
||||
|
||||
// receiveMessages reads from a Redis pub/sub channel and fans out to local subscribers.
|
||||
func (b *RedisBus) receiveMessages(ctx context.Context, pubsub *redis.PubSub, workspaceID string) {
|
||||
ch := pubsub.Channel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var event Event
|
||||
if err := json.Unmarshal([]byte(msg.Payload), &event); err != nil {
|
||||
slog.Error("failed to unmarshal Redis event", "channel", msg.Channel, "error", err)
|
||||
continue
|
||||
}
|
||||
b.fanOutLocally(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fanOutLocally distributes an event to all local subscribers for the event's workspace.
|
||||
func (b *RedisBus) fanOutLocally(event Event) {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
for _, sub := range b.subscribers {
|
||||
if sub.workspaceID != event.WorkspaceID {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case sub.ch <- event:
|
||||
default:
|
||||
slog.Warn("dropping event for slow subscriber", "type", event.Type, "workspace", event.WorkspaceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Package logging provides structured logging using log/slog.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// logging.Setup("info", "json") // call once at startup
|
||||
// slog.Info("something happened", "key", value)
|
||||
//
|
||||
// All application code should use the slog package directly after Setup has
|
||||
// been called — it configures the default slog logger.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Setup configures the default slog logger.
|
||||
//
|
||||
// - level: "debug", "info", "warn", "error" (default "info")
|
||||
// - format: "json" or "text" (default "text")
|
||||
//
|
||||
// After calling Setup, use slog.Info / slog.Error / etc. everywhere.
|
||||
func Setup(level, format string) {
|
||||
SetupWriter(os.Stderr, level, format)
|
||||
}
|
||||
|
||||
// SetupWriter is like Setup but writes to w instead of stderr (useful for tests).
|
||||
func SetupWriter(w io.Writer, level, format string) {
|
||||
lvl := parseLevel(level)
|
||||
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: lvl,
|
||||
}
|
||||
|
||||
var handler slog.Handler
|
||||
switch strings.ToLower(format) {
|
||||
case "json":
|
||||
handler = slog.NewJSONHandler(w, opts)
|
||||
default:
|
||||
handler = slog.NewTextHandler(w, opts)
|
||||
}
|
||||
|
||||
slog.SetDefault(slog.New(handler))
|
||||
}
|
||||
|
||||
func parseLevel(s string) slog.Level {
|
||||
switch strings.ToLower(s) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -537,7 +537,7 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
// Generate reset token
|
||||
token, err := s.store.CreatePasswordReset(user.ID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create password reset: %v", err)
|
||||
slog.Error("failed to create password reset", "error", err)
|
||||
writeJSON(w, http.StatusOK, okResponse)
|
||||
return
|
||||
}
|
||||
@@ -547,11 +547,11 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
resetURL := s.baseURL + "/reset-password/" + token
|
||||
go func() {
|
||||
if err := s.email.SendPasswordReset(context.Background(), user.Email, user.Name, resetURL); err != nil {
|
||||
log.Printf("Failed to send password reset email: %v", err)
|
||||
slog.Error("failed to send password reset email", "error", err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
log.Printf("Password reset token generated (email not configured). Use pad auth reset-password to manage.")
|
||||
slog.Info("password reset token generated (email not configured)")
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, okResponse)
|
||||
@@ -598,7 +598,7 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Invalidate all existing sessions (force logout everywhere)
|
||||
if err := s.store.DeleteUserSessions(user.ID); err != nil {
|
||||
log.Printf("Failed to invalidate sessions after password reset: %v", err)
|
||||
slog.Error("failed to invalidate sessions after password reset", "error", err)
|
||||
}
|
||||
|
||||
// Create a fresh session so the user is logged in
|
||||
|
||||
@@ -3,7 +3,7 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
@@ -89,7 +89,7 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
func writeSSEEvent(w http.ResponseWriter, eventType string, data interface{}) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
log.Printf("events: error marshaling SSE event: %v", err)
|
||||
slog.Error("failed to marshal SSE event", "error", err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, jsonData)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -369,7 +369,7 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
commentInput.Source = source
|
||||
comment, cerr := s.store.CreateComment(workspaceID, updated.ID, commentInput)
|
||||
if cerr != nil {
|
||||
log.Printf("WARNING: failed to create comment on item update %s: %v", updated.ID, cerr)
|
||||
slog.Warn("failed to create comment on item update", "item_id", updated.ID, "error", cerr)
|
||||
}
|
||||
if cerr == nil && comment != nil {
|
||||
s.publishCommentEvent(events.CommentCreated, workspaceID, updated.ID, comment.ID, updated.Title, updated.CollectionSlug, actor, source)
|
||||
|
||||
@@ -2,7 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -149,7 +149,7 @@ func (s *Server) handleInviteMember(w http.ResponseWriter, r *http.Request) {
|
||||
wsName = ws.Name
|
||||
}
|
||||
if err := s.email.SendInvitation(context.Background(), inv.Email, inviterName, wsName, joinURL); err != nil {
|
||||
log.Printf("Failed to send invitation email: %v", err)
|
||||
slog.Error("failed to send invitation email", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -72,6 +72,26 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleHealthLive is a lightweight liveness probe — always returns 200 if the
|
||||
// process is running. Kubernetes uses this to decide whether to restart the pod.
|
||||
func (s *Server) handleHealthLive(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleHealthReady is a readiness probe — returns 200 only when the service
|
||||
// can accept traffic (DB connection healthy). Kubernetes uses this to decide
|
||||
// whether to route traffic to the pod.
|
||||
func (s *Server) handleHealthReady(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.Ping(); err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"status": "not ready",
|
||||
"error": "database unavailable",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
}
|
||||
|
||||
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
type templateInfo struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -130,7 +130,7 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||||
path := r.URL.Path
|
||||
|
||||
// Auth endpoints are always exempt
|
||||
if strings.HasPrefix(path, "/api/v1/auth/") || path == "/api/v1/health" {
|
||||
if strings.HasPrefix(path, "/api/v1/auth/") || path == "/api/v1/health" || strings.HasPrefix(path, "/api/v1/health/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// StructuredLogger is a chi-compatible request logger that writes structured
|
||||
// log entries via slog. It replaces chi's default Logger middleware.
|
||||
func StructuredLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
ww := chimiddleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
duration := time.Since(start)
|
||||
status := ww.Status()
|
||||
|
||||
level := slog.LevelInfo
|
||||
if status >= 500 {
|
||||
level = slog.LevelError
|
||||
} else if status >= 400 {
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
|
||||
attrs := []slog.Attr{
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", status),
|
||||
slog.Duration("duration", duration),
|
||||
slog.Int("bytes", ww.BytesWritten()),
|
||||
}
|
||||
|
||||
if reqID := chimiddleware.GetReqID(r.Context()); reqID != "" {
|
||||
attrs = append(attrs, slog.String("request_id", reqID))
|
||||
}
|
||||
|
||||
if r.URL.RawQuery != "" {
|
||||
attrs = append(attrs, slog.String("query", r.URL.RawQuery))
|
||||
}
|
||||
|
||||
slog.LogAttrs(r.Context(), level, "http request", attrs...)
|
||||
})
|
||||
}
|
||||
@@ -24,9 +24,12 @@ func SecurityHeaders(next http.Handler) http.Handler {
|
||||
// Restrict browser features the app doesn't need
|
||||
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
|
||||
// CSP: allow self-sourced scripts/styles, plus inline styles for Svelte
|
||||
// CSP: allow self-sourced content, inline styles for Svelte component scoping,
|
||||
// and inline scripts for SvelteKit's module bootstrap/hydration.
|
||||
// Without 'unsafe-inline' on script-src, SvelteKit's generated inline <script>
|
||||
// tags are blocked, causing a white screen (especially on mobile browsers).
|
||||
h.Set("Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'")
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
@@ -24,8 +26,9 @@ type Server struct {
|
||||
store *store.Store
|
||||
router *chi.Mux
|
||||
routerOnce sync.Once // ensures setupRouter runs once, after all config
|
||||
httpServer *http.Server // underlying HTTP server (set during ListenAndServe)
|
||||
webFS fs.FS // embedded web UI static files (optional)
|
||||
events *events.Bus // real-time event bus (optional)
|
||||
events events.EventBus // real-time event bus (optional)
|
||||
webhooks *webhooks.Dispatcher // webhook dispatcher (optional)
|
||||
email *email.Sender // transactional email sender (optional)
|
||||
rateLimiters *RateLimiters // per-endpoint rate limiters
|
||||
@@ -57,7 +60,7 @@ func (s *Server) SetBaseURL(url string) {
|
||||
}
|
||||
|
||||
// SetEventBus attaches an event bus for real-time SSE streaming.
|
||||
func (s *Server) SetEventBus(bus *events.Bus) {
|
||||
func (s *Server) SetEventBus(bus events.EventBus) {
|
||||
s.events = bus
|
||||
}
|
||||
|
||||
@@ -112,9 +115,9 @@ func (s *Server) setupRouter() {
|
||||
|
||||
// Middleware
|
||||
r.Use(chimiddleware.RealIP)
|
||||
r.Use(chimiddleware.Logger)
|
||||
r.Use(chimiddleware.Recoverer)
|
||||
r.Use(chimiddleware.RequestID)
|
||||
r.Use(StructuredLogger)
|
||||
r.Use(chimiddleware.Recoverer)
|
||||
r.Use(SecurityHeaders)
|
||||
if s.secureCookies {
|
||||
r.Use(StrictTransportSecurity)
|
||||
@@ -139,6 +142,8 @@ func (s *Server) setupRouter() {
|
||||
// API routes
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Get("/health", s.handleHealth)
|
||||
r.Get("/health/live", s.handleHealthLive)
|
||||
r.Get("/health/ready", s.handleHealthReady)
|
||||
|
||||
// Auth endpoints (exempt from auth middleware)
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
@@ -327,6 +332,7 @@ func (s *Server) setupRouter() {
|
||||
// SetWebUI sets the embedded web UI filesystem for serving the SPA.
|
||||
func (s *Server) SetWebUI(fsys fs.FS) {
|
||||
s.webFS = fsys
|
||||
s.ensureRouter()
|
||||
s.router.Handle("/*", s.spaHandler())
|
||||
}
|
||||
|
||||
@@ -376,8 +382,35 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) ListenAndServe(addr string) error {
|
||||
s.ensureRouter()
|
||||
log.Printf("Pad server listening on %s", addr)
|
||||
return http.ListenAndServe(addr, s.router)
|
||||
|
||||
s.httpServer = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: s.router,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
// WriteTimeout left at 0 — SSE connections are long-lived.
|
||||
// Non-SSE handlers should use per-request context deadlines.
|
||||
}
|
||||
|
||||
slog.Info("Pad server listening", "addr", addr)
|
||||
return s.httpServer.ListenAndServe()
|
||||
}
|
||||
|
||||
// Shutdown gracefully drains in-flight requests and stops the HTTP server.
|
||||
// The provided context controls how long to wait for active connections.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
if s.httpServer == nil {
|
||||
return nil
|
||||
}
|
||||
return s.httpServer.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// Handler returns the configured HTTP handler (router).
|
||||
// Useful for testing with httptest.NewServer.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
s.ensureRouter()
|
||||
return s.router
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
@@ -394,7 +427,7 @@ func jsonContentType(next http.Handler) http.Handler {
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("Error encoding JSON: %v", err)
|
||||
slog.Error("failed to encode JSON response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,7 +444,7 @@ func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
// message to the client. This prevents leaking SQL errors, file paths,
|
||||
// and other internal details.
|
||||
func writeInternalError(w http.ResponseWriter, err error) {
|
||||
log.Printf("internal error: %v", err)
|
||||
slog.Error("internal server error", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "An internal error occurred")
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ func (s *Store) CreateActivity(a models.Activity) (string, error) {
|
||||
}
|
||||
ts := now()
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
_, err := s.db.Exec(s.q(`
|
||||
INSERT INTO activities (id, workspace_id, document_id, action, actor, source, metadata, user_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, a.ID, a.WorkspaceID, nilIfEmpty(a.DocumentID), a.Action, a.Actor, a.Source, a.Metadata, nilIfEmpty(a.UserID), ts)
|
||||
`), a.ID, a.WorkspaceID, nilIfEmpty(a.DocumentID), a.Action, a.Actor, a.Source, a.Metadata, nilIfEmpty(a.UserID), ts)
|
||||
return a.ID, err
|
||||
}
|
||||
|
||||
@@ -50,12 +50,12 @@ func (s *Store) CreateActivityDebounced(a models.Activity) (string, error) {
|
||||
|
||||
// Look for a recent activity to coalesce with.
|
||||
var existingID, existingMeta string
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, metadata FROM activities
|
||||
WHERE document_id = ? AND action = ? AND created_at >= ?
|
||||
AND ((user_id IS NOT NULL AND user_id = ?) OR (user_id IS NULL AND ? = ''))
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`, a.DocumentID, a.Action, cutoff, a.UserID, a.UserID).Scan(&existingID, &existingMeta)
|
||||
`), a.DocumentID, a.Action, cutoff, a.UserID, a.UserID).Scan(&existingID, &existingMeta)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
// No recent match — create a new activity.
|
||||
@@ -69,9 +69,9 @@ func (s *Store) CreateActivityDebounced(a models.Activity) (string, error) {
|
||||
// Merge metadata: accumulate "changes" strings from both old and new.
|
||||
merged := mergeActivityMeta(existingMeta, a.Metadata)
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
_, err = s.db.Exec(s.q(`
|
||||
UPDATE activities SET metadata = ?, created_at = ? WHERE id = ?
|
||||
`, merged, ts, existingID)
|
||||
`), merged, ts, existingID)
|
||||
return existingID, err
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ func (s *Store) ListWorkspaceActivity(workspaceID string, params models.Activity
|
||||
query += fmt.Sprintf(" OFFSET %d", params.Offset)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
rows, err := s.db.Query(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -187,7 +187,7 @@ func (s *Store) ListDocumentActivity(documentID string, params models.ActivityLi
|
||||
query += fmt.Sprintf(" OFFSET %d", params.Offset)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
rows, err := s.db.Query(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -200,14 +200,14 @@ func (s *Store) ListDocumentActivity(documentID string, params models.ActivityLi
|
||||
// ordered newest-first, limited to `limit` results. Used for cursor-based timeline pagination.
|
||||
func (s *Store) ListDocumentActivityBeforeTime(documentID string, before time.Time, beforeID string, limit int) ([]models.Activity, error) {
|
||||
ts := before.Format(time.RFC3339)
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT a.id, a.workspace_id, COALESCE(a.document_id, ''), a.action, a.actor, a.source, a.metadata, COALESCE(a.user_id, ''), a.created_at, COALESCE(u.name, '')
|
||||
FROM activities a
|
||||
LEFT JOIN users u ON a.user_id = u.id
|
||||
WHERE a.document_id = ? AND (a.created_at < ? OR (a.created_at = ? AND a.id < ?))
|
||||
ORDER BY a.created_at DESC, a.id DESC
|
||||
LIMIT ?
|
||||
`, documentID, ts, ts, beforeID, limit)
|
||||
`), documentID, ts, ts, beforeID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@ func (s *Store) CreateAgentRole(workspaceID string, input models.AgentRoleCreate
|
||||
return nil, fmt.Errorf("unique slug: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
_, err = s.db.Exec(s.q(`
|
||||
INSERT INTO agent_roles (id, workspace_id, slug, name, description, icon, tools, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||
`, id, workspaceID, slug, input.Name, input.Description, input.Icon, input.Tools, ts, ts)
|
||||
`), id, workspaceID, slug, input.Name, input.Description, input.Icon, input.Tools, ts, ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create agent role: %w", err)
|
||||
}
|
||||
@@ -41,11 +41,11 @@ func (s *Store) GetAgentRole(workspaceID, idOrSlug string) (*models.AgentRole, e
|
||||
var role models.AgentRole
|
||||
var createdAt, updatedAt string
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, workspace_id, slug, name, description, icon, tools, sort_order, created_at, updated_at
|
||||
FROM agent_roles
|
||||
WHERE workspace_id = ? AND (id = ? OR slug = ?)
|
||||
`, workspaceID, idOrSlug, idOrSlug).Scan(
|
||||
`), workspaceID, idOrSlug, idOrSlug).Scan(
|
||||
&role.ID, &role.WorkspaceID, &role.Slug, &role.Name, &role.Description,
|
||||
&role.Icon, &role.Tools, &role.SortOrder, &createdAt, &updatedAt,
|
||||
)
|
||||
@@ -62,7 +62,7 @@ func (s *Store) GetAgentRole(workspaceID, idOrSlug string) (*models.AgentRole, e
|
||||
}
|
||||
|
||||
func (s *Store) ListAgentRoles(workspaceID string) ([]models.AgentRole, error) {
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT r.id, r.workspace_id, r.slug, r.name, r.description, r.icon, r.tools, r.sort_order,
|
||||
r.created_at, r.updated_at,
|
||||
COUNT(i.id) as item_count
|
||||
@@ -71,7 +71,7 @@ func (s *Store) ListAgentRoles(workspaceID string) ([]models.AgentRole, error) {
|
||||
WHERE r.workspace_id = ?
|
||||
GROUP BY r.id
|
||||
ORDER BY r.sort_order ASC, r.name ASC
|
||||
`, workspaceID)
|
||||
`), workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list agent roles: %w", err)
|
||||
}
|
||||
@@ -137,7 +137,7 @@ func (s *Store) UpdateAgentRole(workspaceID, id string, input models.AgentRoleUp
|
||||
|
||||
args = append(args, existing.ID)
|
||||
query := fmt.Sprintf("UPDATE agent_roles SET %s WHERE id = ?", strings.Join(sets, ", "))
|
||||
_, err = s.db.Exec(query, args...)
|
||||
_, err = s.db.Exec(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update agent role: %w", err)
|
||||
}
|
||||
@@ -146,9 +146,9 @@ func (s *Store) UpdateAgentRole(workspaceID, id string, input models.AgentRoleUp
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAgentRole(workspaceID, id string) error {
|
||||
result, err := s.db.Exec(`
|
||||
result, err := s.db.Exec(s.q(`
|
||||
DELETE FROM agent_roles WHERE workspace_id = ? AND (id = ? OR slug = ?)
|
||||
`, workspaceID, id, id)
|
||||
`), workspaceID, id, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete agent role: %w", err)
|
||||
}
|
||||
@@ -182,15 +182,17 @@ func (s *Store) GetRoleBreakdown(workspaceID string) ([]RoleBreakdown, error) {
|
||||
// Count non-terminal items per role (exclude done/completed/etc. to match board view)
|
||||
termPlaceholders, termArgs := models.DefaultTerminalStatusPlaceholders()
|
||||
roleCountArgs := append([]any{workspaceID}, termArgs...)
|
||||
rows, err := s.db.Query(`
|
||||
SELECT i.agent_role_id, COUNT(*) as cnt, GROUP_CONCAT(DISTINCT u.name) as users
|
||||
jsonExtractStatus := s.dialect.JSONExtractText("i.fields", "status")
|
||||
groupConcatUsers := s.dialect.GroupConcat("u.name", true)
|
||||
rows, err := s.db.Query(s.q(fmt.Sprintf(`
|
||||
SELECT i.agent_role_id, COUNT(*) as cnt, %s as users
|
||||
FROM items i
|
||||
LEFT JOIN users u ON u.id = i.assigned_user_id
|
||||
WHERE i.workspace_id = ? AND i.deleted_at IS NULL
|
||||
AND LOWER(COALESCE(json_extract(i.fields, '$.status'), '')) NOT IN
|
||||
(`+termPlaceholders+`)
|
||||
AND LOWER(COALESCE(%s, '')) NOT IN
|
||||
(%s)
|
||||
GROUP BY i.agent_role_id
|
||||
`, roleCountArgs...)
|
||||
`, groupConcatUsers, jsonExtractStatus, termPlaceholders)), roleCountArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("role breakdown: %w", err)
|
||||
}
|
||||
@@ -401,7 +403,7 @@ func (s *Store) UpdateAgentRoleOrder(workspaceID string, updates []RoleOrderUpda
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare("UPDATE agent_roles SET sort_order = ?, updated_at = ? WHERE id = ? AND workspace_id = ?")
|
||||
stmt, err := tx.Prepare(s.q("UPDATE agent_roles SET sort_order = ?, updated_at = ? WHERE id = ? AND workspace_id = ?"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare role order update: %w", err)
|
||||
}
|
||||
@@ -431,7 +433,7 @@ func (s *Store) UpdateRoleSortOrder(workspaceID string, updates []RoleSortUpdate
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare("UPDATE items SET role_sort_order = ? WHERE id = ? AND workspace_id = ?")
|
||||
stmt, err := tx.Prepare(s.q("UPDATE items SET role_sort_order = ? WHERE id = ? AND workspace_id = ?"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare role sort update: %w", err)
|
||||
}
|
||||
|
||||
@@ -40,10 +40,10 @@ func (s *Store) CreateAPIToken(userID string, input models.APITokenCreate) (*mod
|
||||
wsID = input.WorkspaceID
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
_, err := s.db.Exec(s.q(`
|
||||
INSERT INTO api_tokens (id, workspace_id, user_id, name, token_hash, prefix, scopes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, id, wsID, userID, input.Name, tokenHash, prefix, scopes, ts)
|
||||
`), id, wsID, userID, input.Name, tokenHash, prefix, scopes, ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert api token: %w", err)
|
||||
}
|
||||
@@ -61,12 +61,12 @@ func (s *Store) CreateAPIToken(userID string, input models.APITokenCreate) (*mod
|
||||
|
||||
// ListAPITokens returns all API tokens for a workspace (without secrets).
|
||||
func (s *Store) ListAPITokens(workspaceID string) ([]models.APIToken, error) {
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT id, COALESCE(workspace_id, ''), COALESCE(user_id, ''), name, prefix, scopes, expires_at, last_used_at, created_at
|
||||
FROM api_tokens
|
||||
WHERE workspace_id = ?
|
||||
ORDER BY created_at ASC
|
||||
`, workspaceID)
|
||||
`), workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list api tokens: %w", err)
|
||||
}
|
||||
@@ -85,12 +85,12 @@ func (s *Store) ListAPITokens(workspaceID string) ([]models.APIToken, error) {
|
||||
|
||||
// ListUserAPITokens returns all API tokens owned by a user (without secrets).
|
||||
func (s *Store) ListUserAPITokens(userID string) ([]models.APIToken, error) {
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT id, COALESCE(workspace_id, ''), COALESCE(user_id, ''), name, prefix, scopes, expires_at, last_used_at, created_at
|
||||
FROM api_tokens
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at ASC
|
||||
`, userID)
|
||||
`), userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list user api tokens: %w", err)
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func (s *Store) ListUserAPITokens(userID string) ([]models.APIToken, error) {
|
||||
|
||||
// DeleteAPIToken removes an API token by ID.
|
||||
func (s *Store) DeleteAPIToken(id string) error {
|
||||
result, err := s.db.Exec("DELETE FROM api_tokens WHERE id = ?", id)
|
||||
result, err := s.db.Exec(s.q("DELETE FROM api_tokens WHERE id = ?"), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete api token: %w", err)
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func (s *Store) DeleteAPIToken(id string) error {
|
||||
|
||||
// DeleteUserAPIToken removes an API token by ID, verifying it belongs to the user.
|
||||
func (s *Store) DeleteUserAPIToken(id, userID string) error {
|
||||
result, err := s.db.Exec("DELETE FROM api_tokens WHERE id = ? AND user_id = ?", id, userID)
|
||||
result, err := s.db.Exec(s.q("DELETE FROM api_tokens WHERE id = ? AND user_id = ?"), id, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete user api token: %w", err)
|
||||
}
|
||||
@@ -145,11 +145,11 @@ func (s *Store) ValidateToken(token string) (*models.APIToken, error) {
|
||||
var workspaceID *string
|
||||
var createdAt string
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, workspace_id, user_id, name, prefix, scopes, expires_at, last_used_at, created_at
|
||||
FROM api_tokens
|
||||
WHERE token_hash = ?
|
||||
`, tokenHash).Scan(
|
||||
`), tokenHash).Scan(
|
||||
&t.ID, &workspaceID, &userID, &t.Name, &t.Prefix, &t.Scopes,
|
||||
&expiresAt, &lastUsedAt, &createdAt,
|
||||
)
|
||||
@@ -177,7 +177,7 @@ func (s *Store) ValidateToken(token string) (*models.APIToken, error) {
|
||||
|
||||
// Update last_used_at
|
||||
ts := now()
|
||||
_, _ = s.db.Exec("UPDATE api_tokens SET last_used_at = ? WHERE id = ?", ts, t.ID)
|
||||
_, _ = s.db.Exec(s.q("UPDATE api_tokens SET last_used_at = ? WHERE id = ?"), ts, t.ID)
|
||||
|
||||
return &t, nil
|
||||
}
|
||||
@@ -188,11 +188,11 @@ func (s *Store) getAPIToken(id string) (*models.APIToken, error) {
|
||||
var expiresAt, lastUsedAt, userID, workspaceID *string
|
||||
var createdAt string
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, workspace_id, user_id, name, prefix, scopes, expires_at, last_used_at, created_at
|
||||
FROM api_tokens
|
||||
WHERE id = ?
|
||||
`, id).Scan(
|
||||
`), id).Scan(
|
||||
&t.ID, &workspaceID, &userID, &t.Name, &t.Prefix, &t.Scopes,
|
||||
&expiresAt, &lastUsedAt, &createdAt,
|
||||
)
|
||||
|
||||
@@ -45,10 +45,10 @@ func (s *Store) CreateCollection(workspaceID string, input models.CollectionCrea
|
||||
return nil, fmt.Errorf("unique slug: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
_, err = s.db.Exec(s.q(`
|
||||
INSERT INTO collections (id, workspace_id, name, slug, prefix, icon, description, schema, settings, sort_order, is_default, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, id, workspaceID, input.Name, slug, prefix, icon, description, schema, settings, 0, boolToInt(input.IsDefault), ts, ts)
|
||||
`), id, workspaceID, input.Name, slug, prefix, icon, description, schema, settings, 0, s.dialect.BoolToInt(input.IsDefault), ts, ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert collection: %w", err)
|
||||
}
|
||||
@@ -60,13 +60,13 @@ func (s *Store) GetCollection(id string) (*models.Collection, error) {
|
||||
var c models.Collection
|
||||
var createdAt, updatedAt string
|
||||
var deletedAt *string
|
||||
var isDefault int
|
||||
var isDefault bool
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, workspace_id, name, slug, prefix, icon, description, schema, settings, sort_order, is_default, created_at, updated_at, deleted_at
|
||||
FROM collections
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`, id).Scan(
|
||||
`), id).Scan(
|
||||
&c.ID, &c.WorkspaceID, &c.Name, &c.Slug, &c.Prefix, &c.Icon, &c.Description,
|
||||
&c.Schema, &c.Settings, &c.SortOrder, &isDefault,
|
||||
&createdAt, &updatedAt, &deletedAt,
|
||||
@@ -78,7 +78,7 @@ func (s *Store) GetCollection(id string) (*models.Collection, error) {
|
||||
return nil, fmt.Errorf("get collection: %w", err)
|
||||
}
|
||||
|
||||
c.IsDefault = isDefault == 1
|
||||
c.IsDefault = isDefault
|
||||
c.CreatedAt = parseTime(createdAt)
|
||||
c.UpdatedAt = parseTime(updatedAt)
|
||||
c.DeletedAt = parseTimePtr(deletedAt)
|
||||
@@ -87,10 +87,10 @@ func (s *Store) GetCollection(id string) (*models.Collection, error) {
|
||||
|
||||
func (s *Store) GetCollectionBySlug(workspaceID, slug string) (*models.Collection, error) {
|
||||
var id string
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id FROM collections
|
||||
WHERE workspace_id = ? AND slug = ? AND deleted_at IS NULL
|
||||
`, workspaceID, slug).Scan(&id)
|
||||
`), workspaceID, slug).Scan(&id)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -103,19 +103,20 @@ func (s *Store) GetCollectionBySlug(workspaceID, slug string) (*models.Collectio
|
||||
func (s *Store) ListCollections(workspaceID string) ([]models.Collection, error) {
|
||||
termPlaceholders, termArgs := models.DefaultTerminalStatusPlaceholders()
|
||||
queryArgs := append(termArgs, workspaceID)
|
||||
rows, err := s.db.Query(`
|
||||
jsonExtractStatus := s.dialect.JSONExtractText("i.fields", "status")
|
||||
rows, err := s.db.Query(s.q(fmt.Sprintf(`
|
||||
SELECT c.id, c.workspace_id, c.name, c.slug, c.prefix, c.icon, c.description,
|
||||
c.schema, c.settings, c.sort_order, c.is_default, c.created_at, c.updated_at,
|
||||
COUNT(i.id) as item_count,
|
||||
COUNT(CASE WHEN LOWER(json_extract(i.fields, '$.status')) NOT IN
|
||||
(`+termPlaceholders+`)
|
||||
COUNT(CASE WHEN LOWER(COALESCE(%s, '')) NOT IN
|
||||
(%s)
|
||||
THEN i.id END) as active_item_count
|
||||
FROM collections c
|
||||
LEFT JOIN items i ON i.collection_id = c.id AND i.deleted_at IS NULL
|
||||
WHERE c.workspace_id = ? AND c.deleted_at IS NULL
|
||||
GROUP BY c.id
|
||||
ORDER BY c.sort_order ASC, c.created_at ASC
|
||||
`, queryArgs...)
|
||||
`, jsonExtractStatus, termPlaceholders)), queryArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list collections: %w", err)
|
||||
}
|
||||
@@ -125,7 +126,7 @@ func (s *Store) ListCollections(workspaceID string) ([]models.Collection, error)
|
||||
for rows.Next() {
|
||||
var c models.Collection
|
||||
var createdAt, updatedAt string
|
||||
var isDefault int
|
||||
var isDefault bool
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.WorkspaceID, &c.Name, &c.Slug, &c.Prefix, &c.Icon, &c.Description,
|
||||
&c.Schema, &c.Settings, &c.SortOrder, &isDefault,
|
||||
@@ -133,7 +134,7 @@ func (s *Store) ListCollections(workspaceID string) ([]models.Collection, error)
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.IsDefault = isDefault == 1
|
||||
c.IsDefault = isDefault
|
||||
c.CreatedAt = parseTime(createdAt)
|
||||
c.UpdatedAt = parseTime(updatedAt)
|
||||
result = append(result, c)
|
||||
@@ -196,7 +197,7 @@ func (s *Store) UpdateCollection(id string, input models.CollectionUpdate) (*mod
|
||||
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf("UPDATE collections SET %s WHERE id = ?", strings.Join(sets, ", "))
|
||||
_, err = s.db.Exec(query, args...)
|
||||
_, err = s.db.Exec(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update collection: %w", err)
|
||||
}
|
||||
@@ -206,23 +207,23 @@ func (s *Store) UpdateCollection(id string, input models.CollectionUpdate) (*mod
|
||||
|
||||
func (s *Store) DeleteCollection(id string) error {
|
||||
// Check if it's a default collection
|
||||
var isDefault int
|
||||
err := s.db.QueryRow("SELECT is_default FROM collections WHERE id = ? AND deleted_at IS NULL", id).Scan(&isDefault)
|
||||
var isDefault bool
|
||||
err := s.db.QueryRow(s.q("SELECT is_default FROM collections WHERE id = ? AND deleted_at IS NULL"), id).Scan(&isDefault)
|
||||
if err == sql.ErrNoRows {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("check collection: %w", err)
|
||||
}
|
||||
if isDefault == 1 {
|
||||
if isDefault {
|
||||
return fmt.Errorf("cannot delete default collection")
|
||||
}
|
||||
|
||||
ts := now()
|
||||
result, err := s.db.Exec(`
|
||||
result, err := s.db.Exec(s.q(`
|
||||
UPDATE collections SET deleted_at = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`, ts, ts, id)
|
||||
`), ts, ts, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete collection: %w", err)
|
||||
}
|
||||
@@ -249,15 +250,16 @@ func (s *Store) MigrateItemFieldValues(collectionID string, migrations []models.
|
||||
if oldVal == newVal {
|
||||
continue
|
||||
}
|
||||
fieldPath := fmt.Sprintf("$.%s", m.Field)
|
||||
result, err := s.db.Exec(`
|
||||
jsonSet := s.dialect.JSONSet("fields", m.Field)
|
||||
jsonExtract := s.dialect.JSONExtractText("fields", m.Field)
|
||||
result, err := s.db.Exec(s.q(fmt.Sprintf(`
|
||||
UPDATE items
|
||||
SET fields = json_set(fields, ?, ?),
|
||||
SET fields = %s,
|
||||
updated_at = ?
|
||||
WHERE collection_id = ?
|
||||
AND json_extract(fields, ?) = ?
|
||||
AND %s = ?
|
||||
AND deleted_at IS NULL
|
||||
`, fieldPath, newVal, ts, collectionID, fieldPath, oldVal)
|
||||
`, jsonSet, jsonExtract)), newVal, ts, collectionID, oldVal)
|
||||
if err != nil {
|
||||
return totalAffected, fmt.Errorf("migrate field %s (%s → %s): %w", m.Field, oldVal, newVal, err)
|
||||
}
|
||||
|
||||
+10
-10
@@ -26,9 +26,9 @@ func (s *Store) CreateComment(workspaceID, itemID string, input models.CommentCr
|
||||
author = createdBy
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
_, err := s.db.Exec(s.q(`
|
||||
INSERT INTO comments (id, item_id, workspace_id, author, body, created_by, source, activity_id, parent_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
||||
id, itemID, workspaceID, author, input.Body, createdBy, source,
|
||||
nilIfEmpty(input.ActivityID), nilIfEmpty(input.ParentID), ts, ts,
|
||||
)
|
||||
@@ -41,14 +41,14 @@ func (s *Store) CreateComment(workspaceID, itemID string, input models.CommentCr
|
||||
|
||||
// GetComment returns a single comment by ID.
|
||||
func (s *Store) GetComment(id string) (*models.Comment, error) {
|
||||
row := s.db.QueryRow(`
|
||||
row := s.db.QueryRow(s.q(`
|
||||
SELECT c.id, c.item_id, c.workspace_id, c.author, c.body,
|
||||
c.created_by, c.source, COALESCE(c.activity_id, ''), COALESCE(c.parent_id, ''),
|
||||
c.created_at, c.updated_at,
|
||||
i.title, i.slug
|
||||
FROM comments c
|
||||
JOIN items i ON i.id = c.item_id
|
||||
WHERE c.id = ?`, id)
|
||||
WHERE c.id = ?`), id)
|
||||
|
||||
var c models.Comment
|
||||
var createdAt, updatedAt string
|
||||
@@ -71,13 +71,13 @@ func (s *Store) GetComment(id string) (*models.Comment, error) {
|
||||
|
||||
// ListComments returns all comments for an item, ordered chronologically.
|
||||
func (s *Store) ListComments(itemID string) ([]models.Comment, error) {
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT c.id, c.item_id, c.workspace_id, c.author, c.body,
|
||||
c.created_by, c.source, COALESCE(c.activity_id, ''), COALESCE(c.parent_id, ''),
|
||||
c.created_at, c.updated_at
|
||||
FROM comments c
|
||||
WHERE c.item_id = ?
|
||||
ORDER BY c.created_at ASC`, itemID)
|
||||
ORDER BY c.created_at ASC`), itemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list comments: %w", err)
|
||||
}
|
||||
@@ -105,14 +105,14 @@ func (s *Store) ListComments(itemID string) ([]models.Comment, error) {
|
||||
// ordered newest-first, limited to `limit` results. Used for cursor-based timeline pagination.
|
||||
func (s *Store) ListCommentsBeforeTime(itemID string, before time.Time, beforeID string, limit int) ([]models.Comment, error) {
|
||||
ts := before.Format(time.RFC3339)
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT c.id, c.item_id, c.workspace_id, c.author, c.body,
|
||||
c.created_by, c.source, COALESCE(c.activity_id, ''), COALESCE(c.parent_id, ''),
|
||||
c.created_at, c.updated_at
|
||||
FROM comments c
|
||||
WHERE c.item_id = ? AND (c.created_at < ? OR (c.created_at = ? AND c.id < ?))
|
||||
ORDER BY c.created_at DESC, c.id DESC
|
||||
LIMIT ?`, itemID, ts, ts, beforeID, limit)
|
||||
LIMIT ?`), itemID, ts, ts, beforeID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list comments before time: %w", err)
|
||||
}
|
||||
@@ -138,7 +138,7 @@ func (s *Store) ListCommentsBeforeTime(itemID string, before time.Time, beforeID
|
||||
|
||||
// DeleteComment removes a comment by ID.
|
||||
func (s *Store) DeleteComment(id string) error {
|
||||
result, err := s.db.Exec("DELETE FROM comments WHERE id = ?", id)
|
||||
result, err := s.db.Exec(s.q("DELETE FROM comments WHERE id = ?"), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete comment: %w", err)
|
||||
}
|
||||
@@ -152,6 +152,6 @@ func (s *Store) DeleteComment(id string) error {
|
||||
// CountComments returns the number of comments for an item.
|
||||
func (s *Store) CountComments(itemID string) (int, error) {
|
||||
var count int
|
||||
err := s.db.QueryRow("SELECT COUNT(*) FROM comments WHERE item_id = ?", itemID).Scan(&count)
|
||||
err := s.db.QueryRow(s.q("SELECT COUNT(*) FROM comments WHERE item_id = ?"), itemID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DriverType identifies the database backend.
|
||||
type DriverType string
|
||||
|
||||
const (
|
||||
DriverSQLite DriverType = "sqlite"
|
||||
DriverPostgres DriverType = "postgres"
|
||||
)
|
||||
|
||||
// Dialect encapsulates SQL syntax differences between database backends.
|
||||
// The Store calls dialect methods to generate backend-specific SQL fragments.
|
||||
type Dialect interface {
|
||||
// Driver returns the driver type.
|
||||
Driver() DriverType
|
||||
|
||||
// Placeholder returns the nth parameter placeholder (1-indexed).
|
||||
// SQLite: "?", PostgreSQL: "$1", "$2", etc.
|
||||
Placeholder(n int) string
|
||||
|
||||
// Rebind converts a query with "?" placeholders to the dialect's format.
|
||||
// For SQLite this is a no-op. For PostgreSQL, "?" becomes "$1", "$2", etc.
|
||||
Rebind(query string) string
|
||||
|
||||
// JSONExtractText returns SQL to extract a text value from a JSON column.
|
||||
// SQLite: json_extract(col, '$.key')
|
||||
// PostgreSQL: col->>'key'
|
||||
JSONExtractText(column, key string) string
|
||||
|
||||
// JSONExtractPath returns SQL to extract a value at a dotted path from a JSON column.
|
||||
// SQLite: json_extract(col, '$.path.to.key')
|
||||
// PostgreSQL: col #>> '{path,to,key}'
|
||||
JSONExtractPath(column, path string) string
|
||||
|
||||
// JSONSet returns SQL to set a value at a path in a JSON column.
|
||||
// SQLite: json_set(col, '$.key', ?)
|
||||
// PostgreSQL: jsonb_set(col::jsonb, '{key}', ?::jsonb)
|
||||
// Returns the SQL fragment and any extra placeholders used.
|
||||
JSONSet(column, key string) string
|
||||
|
||||
// JSONRemove returns SQL to remove a key from a JSON column.
|
||||
// SQLite: json_remove(col, '$.key')
|
||||
// PostgreSQL: col::jsonb - 'key'
|
||||
JSONRemove(column, key string) string
|
||||
|
||||
// Now returns the SQL expression for the current UTC timestamp.
|
||||
// SQLite: datetime('now')
|
||||
// PostgreSQL: NOW() AT TIME ZONE 'UTC'
|
||||
Now() string
|
||||
|
||||
// NowRFC3339 returns the SQL expression for current UTC time in RFC3339 format.
|
||||
// SQLite: strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
// PostgreSQL: TO_CHAR(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
NowRFC3339() string
|
||||
|
||||
// GroupConcat returns SQL for string aggregation with a separator.
|
||||
// SQLite: GROUP_CONCAT(DISTINCT expr)
|
||||
// PostgreSQL: STRING_AGG(DISTINCT expr, ',')
|
||||
GroupConcat(expr string, distinct bool) string
|
||||
|
||||
// BoolToInt converts a Go bool to a query parameter value.
|
||||
// SQLite: 0/1 (integers)
|
||||
// PostgreSQL: true/false (native booleans)
|
||||
BoolToInt(b bool) interface{}
|
||||
|
||||
// ILike returns the case-insensitive LIKE operator.
|
||||
// SQLite: LIKE (case-insensitive by default)
|
||||
// PostgreSQL: ILIKE
|
||||
ILike() string
|
||||
|
||||
// Concat returns SQL to concatenate string expressions.
|
||||
// SQLite: expr1 || expr2
|
||||
// PostgreSQL: expr1 || expr2 (same, but useful as abstraction point)
|
||||
Concat(exprs ...string) string
|
||||
|
||||
// FTSMatch returns the full-text search WHERE clause fragment.
|
||||
// SQLite: "table MATCH ?"
|
||||
// PostgreSQL: "table.tsvector_col @@ plainto_tsquery('english', ?)"
|
||||
FTSMatch(table, column string) string
|
||||
|
||||
// FTSSnippet returns SQL for highlighted search result snippets.
|
||||
// SQLite: snippet(fts_table, col_idx, '<mark>', '</mark>', '...', 32)
|
||||
// PostgreSQL: ts_headline('english', col, plainto_tsquery('english', ?))
|
||||
FTSSnippet(ftsTable string, colIndex int, sourceColumn string) string
|
||||
|
||||
// FTSRank returns the column/expression for full-text relevance ranking.
|
||||
// SQLite: rank (built-in FTS5 column)
|
||||
// PostgreSQL: ts_rank(tsvector_col, plainto_tsquery('english', ?))
|
||||
FTSRank(table, column string) string
|
||||
|
||||
// JSONArrayContains returns SQL + the arg to check if a JSON array column
|
||||
// contains a given text value.
|
||||
// SQLite: "column LIKE ?" with arg `%"value"%`
|
||||
// PostgreSQL: "column::jsonb @> ?::jsonb" with arg `["value"]`
|
||||
JSONArrayContains(column, value string) (string, interface{})
|
||||
}
|
||||
|
||||
// ---------- SQLite dialect ----------
|
||||
|
||||
type sqliteDialect struct{}
|
||||
|
||||
func (d *sqliteDialect) Driver() DriverType { return DriverSQLite }
|
||||
|
||||
func (d *sqliteDialect) Placeholder(_ int) string { return "?" }
|
||||
|
||||
func (d *sqliteDialect) Rebind(query string) string { return query }
|
||||
|
||||
func (d *sqliteDialect) JSONExtractText(column, key string) string {
|
||||
return fmt.Sprintf("json_extract(%s, '$.%s')", column, key)
|
||||
}
|
||||
|
||||
func (d *sqliteDialect) JSONExtractPath(column, path string) string {
|
||||
return fmt.Sprintf("json_extract(%s, '$.%s')", column, path)
|
||||
}
|
||||
|
||||
func (d *sqliteDialect) JSONSet(column, key string) string {
|
||||
return fmt.Sprintf("json_set(%s, '$.%s', ?)", column, key)
|
||||
}
|
||||
|
||||
func (d *sqliteDialect) JSONRemove(column, key string) string {
|
||||
return fmt.Sprintf("json_remove(%s, '$.%s')", column, key)
|
||||
}
|
||||
|
||||
func (d *sqliteDialect) Now() string {
|
||||
return "datetime('now')"
|
||||
}
|
||||
|
||||
func (d *sqliteDialect) NowRFC3339() string {
|
||||
return "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"
|
||||
}
|
||||
|
||||
func (d *sqliteDialect) GroupConcat(expr string, distinct bool) string {
|
||||
if distinct {
|
||||
return fmt.Sprintf("GROUP_CONCAT(DISTINCT %s)", expr)
|
||||
}
|
||||
return fmt.Sprintf("GROUP_CONCAT(%s)", expr)
|
||||
}
|
||||
|
||||
func (d *sqliteDialect) BoolToInt(b bool) interface{} {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (d *sqliteDialect) ILike() string { return "LIKE" }
|
||||
|
||||
func (d *sqliteDialect) Concat(exprs ...string) string {
|
||||
return strings.Join(exprs, " || ")
|
||||
}
|
||||
|
||||
func (d *sqliteDialect) FTSMatch(table, _ string) string {
|
||||
return fmt.Sprintf("%s MATCH ?", table)
|
||||
}
|
||||
|
||||
func (d *sqliteDialect) FTSSnippet(ftsTable string, colIndex int, _ string) string {
|
||||
return fmt.Sprintf("snippet(%s, %d, '<mark>', '</mark>', '...', 32)", ftsTable, colIndex)
|
||||
}
|
||||
|
||||
func (d *sqliteDialect) FTSRank(_, _ string) string {
|
||||
return "rank"
|
||||
}
|
||||
|
||||
func (d *sqliteDialect) JSONArrayContains(column, value string) (string, interface{}) {
|
||||
return column + " LIKE ?", "%\"" + value + "\"%"
|
||||
}
|
||||
|
||||
// ---------- PostgreSQL dialect ----------
|
||||
|
||||
type postgresDialect struct{}
|
||||
|
||||
func (d *postgresDialect) Driver() DriverType { return DriverPostgres }
|
||||
|
||||
func (d *postgresDialect) Placeholder(n int) string {
|
||||
return fmt.Sprintf("$%d", n)
|
||||
}
|
||||
|
||||
func (d *postgresDialect) Rebind(query string) string {
|
||||
return rebindQuery(query)
|
||||
}
|
||||
|
||||
func (d *postgresDialect) JSONExtractText(column, key string) string {
|
||||
return fmt.Sprintf("%s->>'%s'", column, key)
|
||||
}
|
||||
|
||||
func (d *postgresDialect) JSONExtractPath(column, path string) string {
|
||||
parts := strings.Split(path, ".")
|
||||
return fmt.Sprintf("%s #>> '{%s}'", column, strings.Join(parts, ","))
|
||||
}
|
||||
|
||||
func (d *postgresDialect) JSONSet(column, key string) string {
|
||||
return fmt.Sprintf("jsonb_set(COALESCE(%s, '{}')::jsonb, '{%s}', to_jsonb(?::text))", column, key)
|
||||
}
|
||||
|
||||
func (d *postgresDialect) JSONRemove(column, key string) string {
|
||||
return fmt.Sprintf("(%s::jsonb - '%s')", column, key)
|
||||
}
|
||||
|
||||
func (d *postgresDialect) Now() string {
|
||||
return "(NOW() AT TIME ZONE 'UTC')"
|
||||
}
|
||||
|
||||
func (d *postgresDialect) NowRFC3339() string {
|
||||
return "TO_CHAR(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"')"
|
||||
}
|
||||
|
||||
func (d *postgresDialect) GroupConcat(expr string, distinct bool) string {
|
||||
if distinct {
|
||||
return fmt.Sprintf("STRING_AGG(DISTINCT %s, ',')", expr)
|
||||
}
|
||||
return fmt.Sprintf("STRING_AGG(%s, ',')", expr)
|
||||
}
|
||||
|
||||
func (d *postgresDialect) BoolToInt(b bool) interface{} {
|
||||
return b
|
||||
}
|
||||
|
||||
func (d *postgresDialect) ILike() string { return "ILIKE" }
|
||||
|
||||
func (d *postgresDialect) Concat(exprs ...string) string {
|
||||
return strings.Join(exprs, " || ")
|
||||
}
|
||||
|
||||
func (d *postgresDialect) FTSMatch(table, column string) string {
|
||||
return fmt.Sprintf("%s.%s @@ plainto_tsquery('english', ?)", table, column)
|
||||
}
|
||||
|
||||
func (d *postgresDialect) FTSSnippet(_ string, _ int, sourceColumn string) string {
|
||||
return fmt.Sprintf("ts_headline('english', %s, plainto_tsquery('english', ?), 'StartSel=<mark>,StopSel=</mark>,MaxFragments=1,MaxWords=32')", sourceColumn)
|
||||
}
|
||||
|
||||
func (d *postgresDialect) FTSRank(table, column string) string {
|
||||
return fmt.Sprintf("ts_rank(%s.%s, plainto_tsquery('english', ?))", table, column)
|
||||
}
|
||||
|
||||
func (d *postgresDialect) JSONArrayContains(column, value string) (string, interface{}) {
|
||||
return column + "::jsonb @> ?::jsonb", `["` + value + `"]`
|
||||
}
|
||||
|
||||
// ---------- Helper ----------
|
||||
|
||||
// rebindQuery converts "?" placeholders to PostgreSQL's "$1", "$2", etc.
|
||||
// Respects string literals (single quotes) and does not modify "?" inside them.
|
||||
func rebindQuery(query string) string {
|
||||
var buf strings.Builder
|
||||
buf.Grow(len(query) + 16)
|
||||
n := 0
|
||||
inString := false
|
||||
for i := 0; i < len(query); i++ {
|
||||
ch := query[i]
|
||||
if ch == '\'' {
|
||||
inString = !inString
|
||||
buf.WriteByte(ch)
|
||||
} else if ch == '?' && !inString {
|
||||
n++
|
||||
fmt.Fprintf(&buf, "$%d", n)
|
||||
} else {
|
||||
buf.WriteByte(ch)
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRebindQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"no params", "SELECT 1", "SELECT 1"},
|
||||
{"single param", "SELECT * FROM t WHERE id = ?", "SELECT * FROM t WHERE id = $1"},
|
||||
{"multiple params", "INSERT INTO t (a, b, c) VALUES (?, ?, ?)", "INSERT INTO t (a, b, c) VALUES ($1, $2, $3)"},
|
||||
{"string literal preserved", "SELECT * FROM t WHERE name = 'what?' AND id = ?", "SELECT * FROM t WHERE name = 'what?' AND id = $1"},
|
||||
{"mixed", "SELECT * FROM t WHERE a = ? AND b = 'foo?' AND c = ?", "SELECT * FROM t WHERE a = $1 AND b = 'foo?' AND c = $2"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := rebindQuery(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("rebindQuery(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteDialect(t *testing.T) {
|
||||
d := &sqliteDialect{}
|
||||
|
||||
if d.Driver() != DriverSQLite {
|
||||
t.Errorf("expected DriverSQLite, got %v", d.Driver())
|
||||
}
|
||||
if got := d.JSONExtractText("i.fields", "status"); got != "json_extract(i.fields, '$.status')" {
|
||||
t.Errorf("JSONExtractText = %q", got)
|
||||
}
|
||||
if got := d.Now(); got != "datetime('now')" {
|
||||
t.Errorf("Now = %q", got)
|
||||
}
|
||||
if got := d.FTSMatch("items_fts", "search_vector"); got != "items_fts MATCH ?" {
|
||||
t.Errorf("FTSMatch = %q", got)
|
||||
}
|
||||
if got := d.GroupConcat("u.name", true); got != "GROUP_CONCAT(DISTINCT u.name)" {
|
||||
t.Errorf("GroupConcat = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresDialect(t *testing.T) {
|
||||
d := &postgresDialect{}
|
||||
|
||||
if d.Driver() != DriverPostgres {
|
||||
t.Errorf("expected DriverPostgres, got %v", d.Driver())
|
||||
}
|
||||
if got := d.Placeholder(3); got != "$3" {
|
||||
t.Errorf("Placeholder(3) = %q", got)
|
||||
}
|
||||
if got := d.JSONExtractText("i.fields", "status"); got != "i.fields->>'status'" {
|
||||
t.Errorf("JSONExtractText = %q", got)
|
||||
}
|
||||
if got := d.JSONRemove("fields", "phase"); got != "(fields::jsonb - 'phase')" {
|
||||
t.Errorf("JSONRemove = %q", got)
|
||||
}
|
||||
if got := d.GroupConcat("u.name", true); got != "STRING_AGG(DISTINCT u.name, ',')" {
|
||||
t.Errorf("GroupConcat = %q", got)
|
||||
}
|
||||
if got := d.ILike(); got != "ILIKE" {
|
||||
t.Errorf("ILike = %q", got)
|
||||
}
|
||||
}
|
||||
+69
-53
@@ -29,28 +29,42 @@ func (s *Store) ListDocuments(workspaceID string, params models.DocumentListPara
|
||||
args = append(args, params.Status)
|
||||
}
|
||||
if params.Tag != "" {
|
||||
// Search within JSON array
|
||||
query += " AND tags LIKE ?"
|
||||
args = append(args, "%\""+params.Tag+"\"%")
|
||||
tagExpr, tagArg := s.dialect.JSONArrayContains("tags", params.Tag)
|
||||
query += " AND " + tagExpr
|
||||
args = append(args, tagArg)
|
||||
}
|
||||
if params.Pinned != nil {
|
||||
if *params.Pinned {
|
||||
query += " AND pinned = 1"
|
||||
query += " AND pinned = TRUE"
|
||||
} else {
|
||||
query += " AND pinned = 0"
|
||||
query += " AND pinned = FALSE"
|
||||
}
|
||||
}
|
||||
if params.Query != "" {
|
||||
// Use FTS5 for search
|
||||
query = `
|
||||
SELECT d.id, d.workspace_id, d.title, d.slug, d.content, d.doc_type, d.status, d.tags,
|
||||
d.pinned, d.sort_order, d.created_by, d.last_modified_by, d.source,
|
||||
d.created_at, d.updated_at
|
||||
FROM documents d
|
||||
JOIN documents_fts fts ON d.rowid = fts.rowid
|
||||
WHERE d.workspace_id = ? AND d.deleted_at IS NULL
|
||||
AND documents_fts MATCH ?
|
||||
`
|
||||
// Use FTS for search
|
||||
if s.dialect.Driver() == DriverSQLite {
|
||||
ftsMatch := s.dialect.FTSMatch("documents_fts", "search_vector")
|
||||
query = fmt.Sprintf(`
|
||||
SELECT d.id, d.workspace_id, d.title, d.slug, d.content, d.doc_type, d.status, d.tags,
|
||||
d.pinned, d.sort_order, d.created_by, d.last_modified_by, d.source,
|
||||
d.created_at, d.updated_at
|
||||
FROM documents d
|
||||
JOIN documents_fts fts ON d.rowid = fts.rowid
|
||||
WHERE d.workspace_id = ? AND d.deleted_at IS NULL
|
||||
AND %s
|
||||
`, ftsMatch)
|
||||
} else {
|
||||
// PostgreSQL: search_vector lives on the documents table (aliased as "d").
|
||||
ftsMatch := s.dialect.FTSMatch("d", "search_vector")
|
||||
query = fmt.Sprintf(`
|
||||
SELECT d.id, d.workspace_id, d.title, d.slug, d.content, d.doc_type, d.status, d.tags,
|
||||
d.pinned, d.sort_order, d.created_by, d.last_modified_by, d.source,
|
||||
d.created_at, d.updated_at
|
||||
FROM documents d
|
||||
WHERE d.workspace_id = ? AND d.deleted_at IS NULL
|
||||
AND %s
|
||||
`, ftsMatch)
|
||||
}
|
||||
args = []interface{}{workspaceID, params.Query}
|
||||
|
||||
if params.Type != "" {
|
||||
@@ -83,12 +97,20 @@ func (s *Store) ListDocuments(workspaceID string, params models.DocumentListPara
|
||||
}
|
||||
|
||||
if params.Query != "" {
|
||||
query += fmt.Sprintf(" ORDER BY rank, d.%s %s", sortCol, order)
|
||||
if s.dialect.Driver() == DriverPostgres {
|
||||
// PostgreSQL ts_rank(): higher = more relevant → DESC
|
||||
ftsRank := s.dialect.FTSRank("d", "search_vector")
|
||||
query += fmt.Sprintf(" ORDER BY %s DESC, d.%s %s", ftsRank, sortCol, order)
|
||||
args = append(args, params.Query) // extra placeholder for ts_rank
|
||||
} else {
|
||||
// SQLite FTS5: rank is a hidden column on the FTS JOIN (ascending = better)
|
||||
query += fmt.Sprintf(" ORDER BY rank, d.%s %s", sortCol, order)
|
||||
}
|
||||
} else {
|
||||
query += fmt.Sprintf(" ORDER BY pinned DESC, %s %s", sortCol, order)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
rows, err := s.db.Query(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list documents: %w", err)
|
||||
}
|
||||
@@ -131,12 +153,12 @@ func (s *Store) CreateDocument(workspaceID string, input models.DocumentCreate)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
_, err = s.db.Exec(s.q(`
|
||||
INSERT INTO documents (id, workspace_id, title, slug, content, doc_type, status, tags,
|
||||
pinned, sort_order, created_by, last_modified_by, source, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)
|
||||
`, id, workspaceID, input.Title, slug, input.Content, docType, status, tags,
|
||||
boolToInt(input.Pinned), createdBy, createdBy, source, ts, ts)
|
||||
`), id, workspaceID, input.Title, slug, input.Content, docType, status, tags,
|
||||
s.dialect.BoolToInt(input.Pinned), createdBy, createdBy, source, ts, ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert document: %w", err)
|
||||
}
|
||||
@@ -148,15 +170,15 @@ func (s *Store) GetDocument(id string) (*models.Document, error) {
|
||||
var d models.Document
|
||||
var createdAt, updatedAt string
|
||||
var deletedAt *string
|
||||
var pinned int
|
||||
var pinned bool
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, workspace_id, title, slug, content, doc_type, status, tags,
|
||||
pinned, sort_order, created_by, last_modified_by, source,
|
||||
created_at, updated_at, deleted_at
|
||||
FROM documents
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`, id).Scan(
|
||||
`), id).Scan(
|
||||
&d.ID, &d.WorkspaceID, &d.Title, &d.Slug, &d.Content, &d.DocType, &d.Status, &d.Tags,
|
||||
&pinned, &d.SortOrder, &d.CreatedBy, &d.LastModifiedBy, &d.Source,
|
||||
&createdAt, &updatedAt, &deletedAt,
|
||||
@@ -168,7 +190,7 @@ func (s *Store) GetDocument(id string) (*models.Document, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.Pinned = pinned == 1
|
||||
d.Pinned = pinned
|
||||
d.CreatedAt = parseTime(createdAt)
|
||||
d.UpdatedAt = parseTime(updatedAt)
|
||||
d.DeletedAt = parseTimePtr(deletedAt)
|
||||
@@ -177,10 +199,10 @@ func (s *Store) GetDocument(id string) (*models.Document, error) {
|
||||
|
||||
func (s *Store) GetDocumentByTitle(workspaceID, title string) (*models.Document, error) {
|
||||
var id string
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id FROM documents
|
||||
WHERE workspace_id = ? AND title = ? AND deleted_at IS NULL
|
||||
`, workspaceID, title).Scan(&id)
|
||||
`), workspaceID, title).Scan(&id)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -234,17 +256,17 @@ func (s *Store) UpdateDocument(id string, input models.DocumentUpdate) (*models.
|
||||
// Store a reverse diff (patch from new → old) instead of full content.
|
||||
// Falls back to full content if the diff isn't meaningfully smaller.
|
||||
versionContent := existing.Content
|
||||
isDiff := 0
|
||||
isDiff := false
|
||||
patch := diff.CreateReversePatch(existing.Content, *input.Content)
|
||||
if diff.IsDiffSmaller(patch, existing.Content) {
|
||||
versionContent = patch
|
||||
isDiff = 1
|
||||
isDiff = true
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
_, err = tx.Exec(s.q(`
|
||||
INSERT INTO versions (id, document_id, content, change_summary, created_by, source, is_diff, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, vid, id, versionContent, input.ChangeSummary, createdBy, source, isDiff, ts)
|
||||
`), vid, id, versionContent, input.ChangeSummary, createdBy, source, s.dialect.BoolToInt(isDiff), ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create version: %w", err)
|
||||
}
|
||||
@@ -296,7 +318,7 @@ func (s *Store) UpdateDocument(id string, input models.DocumentUpdate) (*models.
|
||||
}
|
||||
if input.Pinned != nil {
|
||||
sets = append(sets, "pinned = ?")
|
||||
args = append(args, boolToInt(*input.Pinned))
|
||||
args = append(args, s.dialect.BoolToInt(*input.Pinned))
|
||||
}
|
||||
if input.SortOrder != nil {
|
||||
sets = append(sets, "sort_order = ?")
|
||||
@@ -313,7 +335,7 @@ func (s *Store) UpdateDocument(id string, input models.DocumentUpdate) (*models.
|
||||
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf("UPDATE documents SET %s WHERE id = ?", strings.Join(sets, ", "))
|
||||
_, err = tx.Exec(query, args...)
|
||||
_, err = tx.Exec(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update document: %w", err)
|
||||
}
|
||||
@@ -328,10 +350,10 @@ func (s *Store) UpdateDocument(id string, input models.DocumentUpdate) (*models.
|
||||
func (s *Store) updateLinksInTx(tx *sql.Tx, workspaceID, oldTitle, newTitle string) error {
|
||||
// Find all documents in the workspace that contain [[oldTitle]]
|
||||
searchTerm := "[[" + oldTitle + "]]"
|
||||
rows, err := tx.Query(`
|
||||
rows, err := tx.Query(s.q(`
|
||||
SELECT id, content FROM documents
|
||||
WHERE workspace_id = ? AND deleted_at IS NULL AND content LIKE ?
|
||||
`, workspaceID, "%"+searchTerm+"%")
|
||||
`), workspaceID, "%"+searchTerm+"%")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -355,7 +377,7 @@ func (s *Store) updateLinksInTx(tx *sql.Tx, workspaceID, oldTitle, newTitle stri
|
||||
}
|
||||
|
||||
for _, du := range updates {
|
||||
_, err = tx.Exec("UPDATE documents SET content = ? WHERE id = ?", du.content, du.id)
|
||||
_, err = tx.Exec(s.q("UPDATE documents SET content = ? WHERE id = ?"), du.content, du.id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -365,10 +387,10 @@ func (s *Store) updateLinksInTx(tx *sql.Tx, workspaceID, oldTitle, newTitle stri
|
||||
|
||||
func (s *Store) DeleteDocument(id string) error {
|
||||
ts := now()
|
||||
result, err := s.db.Exec(`
|
||||
result, err := s.db.Exec(s.q(`
|
||||
UPDATE documents SET deleted_at = ?, updated_at = ?, status = 'archived'
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`, ts, ts, id)
|
||||
`), ts, ts, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -381,10 +403,10 @@ func (s *Store) DeleteDocument(id string) error {
|
||||
|
||||
func (s *Store) RestoreDocument(id string) (*models.Document, error) {
|
||||
ts := now()
|
||||
result, err := s.db.Exec(`
|
||||
result, err := s.db.Exec(s.q(`
|
||||
UPDATE documents SET deleted_at = NULL, updated_at = ?, status = 'draft'
|
||||
WHERE id = ? AND deleted_at IS NOT NULL
|
||||
`, ts, id)
|
||||
`), ts, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -455,7 +477,7 @@ func (s *Store) BulkRead(ids []string) ([]models.Document, error) {
|
||||
WHERE id IN (%s) AND deleted_at IS NULL
|
||||
`, strings.Join(placeholders, ","))
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
rows, err := s.db.Query(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -466,13 +488,13 @@ func (s *Store) BulkRead(ids []string) ([]models.Document, error) {
|
||||
|
||||
func (s *Store) GetBacklinks(workspaceID, documentTitle string) ([]models.Document, error) {
|
||||
searchTerm := "[[" + documentTitle + "]]"
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT id, workspace_id, title, slug, content, doc_type, status, tags,
|
||||
pinned, sort_order, created_by, last_modified_by, source,
|
||||
created_at, updated_at
|
||||
FROM documents
|
||||
WHERE workspace_id = ? AND deleted_at IS NULL AND content LIKE ?
|
||||
`, workspaceID, "%"+searchTerm+"%")
|
||||
`), workspaceID, "%"+searchTerm+"%")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -501,7 +523,7 @@ func (s *Store) GetLinks(workspaceID, content string) ([]models.Document, error)
|
||||
WHERE workspace_id = ? AND deleted_at IS NULL AND title IN (%s)
|
||||
`, strings.Join(placeholders, ","))
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
rows, err := s.db.Query(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -516,7 +538,7 @@ func (s *Store) GetContext(workspaceID string, types []string, includeContent bo
|
||||
created_at, updated_at
|
||||
FROM documents
|
||||
WHERE workspace_id = ? AND deleted_at IS NULL
|
||||
AND (status = 'active' OR pinned = 1)
|
||||
AND (status = 'active' OR pinned = TRUE)
|
||||
`
|
||||
args := []interface{}{workspaceID}
|
||||
|
||||
@@ -531,7 +553,7 @@ func (s *Store) GetContext(workspaceID string, types []string, includeContent bo
|
||||
|
||||
query += " ORDER BY pinned DESC, updated_at DESC"
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
rows, err := s.db.Query(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -556,7 +578,7 @@ func scanDocuments(rows *sql.Rows) ([]models.Document, error) {
|
||||
for rows.Next() {
|
||||
var d models.Document
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
var pinned bool
|
||||
if err := rows.Scan(
|
||||
&d.ID, &d.WorkspaceID, &d.Title, &d.Slug, &d.Content, &d.DocType, &d.Status, &d.Tags,
|
||||
&pinned, &d.SortOrder, &d.CreatedBy, &d.LastModifiedBy, &d.Source,
|
||||
@@ -564,7 +586,7 @@ func scanDocuments(rows *sql.Rows) ([]models.Document, error) {
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Pinned = pinned == 1
|
||||
d.Pinned = pinned
|
||||
d.CreatedAt = parseTime(createdAt)
|
||||
d.UpdatedAt = parseTime(updatedAt)
|
||||
docs = append(docs, d)
|
||||
@@ -572,9 +594,3 @@ func scanDocuments(rows *sql.Rows) ([]models.Document, error) {
|
||||
return docs, rows.Err()
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
+36
-46
@@ -30,21 +30,21 @@ func (s *Store) ExportWorkspace(slug string) (*models.WorkspaceExport, error) {
|
||||
}
|
||||
|
||||
// Collections
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT id, name, slug, icon, description, schema, settings, prefix, sort_order, is_default, created_at, updated_at
|
||||
FROM collections WHERE workspace_id = ? AND deleted_at IS NULL
|
||||
ORDER BY sort_order, name`, ws.ID)
|
||||
ORDER BY sort_order, name`), ws.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export collections: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var c models.CollectionExport
|
||||
var isDefault int
|
||||
var isDefault bool
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Slug, &c.Icon, &c.Description, &c.Schema, &c.Settings, &c.Prefix, &c.SortOrder, &isDefault, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan collection: %w", err)
|
||||
}
|
||||
c.IsDefault = isDefault == 1
|
||||
c.IsDefault = isDefault
|
||||
export.Collections = append(export.Collections, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -52,22 +52,22 @@ func (s *Store) ExportWorkspace(slug string) (*models.WorkspaceExport, error) {
|
||||
}
|
||||
|
||||
// Items
|
||||
itemRows, err := s.db.Query(`
|
||||
itemRows, err := s.db.Query(s.q(`
|
||||
SELECT id, collection_id, title, slug, content, fields, tags, pinned, sort_order,
|
||||
COALESCE(parent_id, ''), created_by, last_modified_by, source, COALESCE(item_number, 0), created_at, updated_at
|
||||
FROM items WHERE workspace_id = ? AND deleted_at IS NULL
|
||||
ORDER BY collection_id, sort_order, created_at`, ws.ID)
|
||||
ORDER BY collection_id, sort_order, created_at`), ws.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export items: %w", err)
|
||||
}
|
||||
defer itemRows.Close()
|
||||
for itemRows.Next() {
|
||||
var it models.ItemExport
|
||||
var pinned int
|
||||
var pinned bool
|
||||
if err := itemRows.Scan(&it.ID, &it.CollectionID, &it.Title, &it.Slug, &it.Content, &it.Fields, &it.Tags, &pinned, &it.SortOrder, &it.ParentID, &it.CreatedBy, &it.LastModifiedBy, &it.Source, &it.ItemNumber, &it.CreatedAt, &it.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan item: %w", err)
|
||||
}
|
||||
it.Pinned = pinned == 1
|
||||
it.Pinned = pinned
|
||||
export.Items = append(export.Items, it)
|
||||
}
|
||||
if err := itemRows.Err(); err != nil {
|
||||
@@ -75,12 +75,12 @@ func (s *Store) ExportWorkspace(slug string) (*models.WorkspaceExport, error) {
|
||||
}
|
||||
|
||||
// Comments
|
||||
commentRows, err := s.db.Query(`
|
||||
commentRows, err := s.db.Query(s.q(`
|
||||
SELECT c.id, c.item_id, c.author, c.body, c.created_by, c.source, c.created_at, c.updated_at
|
||||
FROM comments c
|
||||
JOIN items i ON c.item_id = i.id
|
||||
WHERE c.workspace_id = ? AND i.deleted_at IS NULL
|
||||
ORDER BY c.created_at`, ws.ID)
|
||||
ORDER BY c.created_at`), ws.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export comments: %w", err)
|
||||
}
|
||||
@@ -97,10 +97,10 @@ func (s *Store) ExportWorkspace(slug string) (*models.WorkspaceExport, error) {
|
||||
}
|
||||
|
||||
// Item links
|
||||
linkRows, err := s.db.Query(`
|
||||
linkRows, err := s.db.Query(s.q(`
|
||||
SELECT id, source_id, target_id, link_type, created_by, created_at
|
||||
FROM item_links WHERE workspace_id = ?
|
||||
ORDER BY created_at`, ws.ID)
|
||||
ORDER BY created_at`), ws.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export item links: %w", err)
|
||||
}
|
||||
@@ -117,23 +117,23 @@ func (s *Store) ExportWorkspace(slug string) (*models.WorkspaceExport, error) {
|
||||
}
|
||||
|
||||
// Item versions
|
||||
versionRows, err := s.db.Query(`
|
||||
versionRows, err := s.db.Query(s.q(`
|
||||
SELECT v.id, v.item_id, v.content, v.change_summary, v.created_by, v.source, v.is_diff, v.created_at
|
||||
FROM item_versions v
|
||||
JOIN items i ON v.item_id = i.id
|
||||
WHERE i.workspace_id = ? AND i.deleted_at IS NULL
|
||||
ORDER BY v.created_at`, ws.ID)
|
||||
ORDER BY v.created_at`), ws.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export item versions: %w", err)
|
||||
}
|
||||
defer versionRows.Close()
|
||||
for versionRows.Next() {
|
||||
var ver models.ItemVersionExport
|
||||
var isDiff int
|
||||
var isDiff bool
|
||||
if err := versionRows.Scan(&ver.ID, &ver.ItemID, &ver.Content, &ver.ChangeSummary, &ver.CreatedBy, &ver.Source, &isDiff, &ver.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan item version: %w", err)
|
||||
}
|
||||
ver.IsDiff = isDiff == 1
|
||||
ver.IsDiff = isDiff
|
||||
export.ItemVersions = append(export.ItemVersions, ver)
|
||||
}
|
||||
if err := versionRows.Err(); err != nil {
|
||||
@@ -185,15 +185,10 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (*
|
||||
newCollID := newID()
|
||||
collMap[c.ID] = newCollID
|
||||
|
||||
isDefault := 0
|
||||
if c.IsDefault {
|
||||
isDefault = 1
|
||||
}
|
||||
|
||||
_, err := tx.Exec(`
|
||||
_, err := tx.Exec(s.q(`
|
||||
INSERT INTO collections (id, workspace_id, name, slug, icon, description, schema, settings, prefix, sort_order, is_default, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
newCollID, ws.ID, c.Name, c.Slug, c.Icon, c.Description, c.Schema, c.Settings, c.Prefix, c.SortOrder, isDefault,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
||||
newCollID, ws.ID, c.Name, c.Slug, c.Icon, c.Description, c.Schema, c.Settings, c.Prefix, c.SortOrder, s.dialect.BoolToInt(c.IsDefault),
|
||||
c.CreatedAt, c.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("import collection %s: %w", c.Name, err)
|
||||
@@ -209,11 +204,6 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (*
|
||||
continue // skip orphaned items
|
||||
}
|
||||
|
||||
pinned := 0
|
||||
if it.Pinned {
|
||||
pinned = 1
|
||||
}
|
||||
|
||||
// On first pass, parent_id may refer to an item not yet created, so use empty
|
||||
parentID := ""
|
||||
if it.ParentID != "" {
|
||||
@@ -222,10 +212,10 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (*
|
||||
}
|
||||
}
|
||||
|
||||
_, err := tx.Exec(`
|
||||
_, err := tx.Exec(s.q(`
|
||||
INSERT INTO items (id, workspace_id, collection_id, title, slug, content, fields, tags, pinned, sort_order, parent_id, created_by, last_modified_by, source, item_number, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?)`,
|
||||
newItemID, ws.ID, newCollID, it.Title, it.Slug, it.Content, it.Fields, it.Tags, pinned, it.SortOrder,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?)`),
|
||||
newItemID, ws.ID, newCollID, it.Title, it.Slug, it.Content, it.Fields, it.Tags, s.dialect.BoolToInt(it.Pinned), it.SortOrder,
|
||||
parentID, it.CreatedBy, it.LastModifiedBy, it.Source, it.ItemNumber,
|
||||
it.CreatedAt, it.UpdatedAt)
|
||||
if err != nil {
|
||||
@@ -247,7 +237,7 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (*
|
||||
parentID = mapped
|
||||
}
|
||||
}
|
||||
_, err := tx.Exec(`UPDATE items SET fields = ?, parent_id = NULLIF(?, '') WHERE id = ?`,
|
||||
_, err := tx.Exec(s.q(`UPDATE items SET fields = ?, parent_id = NULLIF(?, '') WHERE id = ?`),
|
||||
fields, parentID, newItemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remap item %s: %w", it.Title, err)
|
||||
@@ -260,9 +250,9 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (*
|
||||
if newItemID == "" {
|
||||
continue
|
||||
}
|
||||
_, err := tx.Exec(`
|
||||
_, err := tx.Exec(s.q(`
|
||||
INSERT INTO comments (id, item_id, workspace_id, author, body, created_by, source, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
||||
newID(), newItemID, ws.ID, cm.Author, cm.Body, cm.CreatedBy, cm.Source,
|
||||
cm.CreatedAt, cm.UpdatedAt)
|
||||
if err != nil {
|
||||
@@ -277,9 +267,9 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (*
|
||||
if newSourceID == "" || newTargetID == "" {
|
||||
continue
|
||||
}
|
||||
_, err := tx.Exec(`
|
||||
_, err := tx.Exec(s.q(`
|
||||
INSERT INTO item_links (id, workspace_id, source_id, target_id, link_type, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`),
|
||||
newID(), ws.ID, newSourceID, newTargetID, lk.LinkType, lk.CreatedBy,
|
||||
lk.CreatedAt)
|
||||
if err != nil {
|
||||
@@ -294,14 +284,10 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (*
|
||||
if newItemID == "" {
|
||||
continue
|
||||
}
|
||||
isDiff := 0
|
||||
if ver.IsDiff {
|
||||
isDiff = 1
|
||||
}
|
||||
_, err := tx.Exec(`
|
||||
_, err := tx.Exec(s.q(`
|
||||
INSERT INTO item_versions (id, item_id, content, change_summary, created_by, source, is_diff, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
newID(), newItemID, ver.Content, ver.ChangeSummary, ver.CreatedBy, ver.Source, isDiff,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
|
||||
newID(), newItemID, ver.Content, ver.ChangeSummary, ver.CreatedBy, ver.Source, s.dialect.BoolToInt(ver.IsDiff),
|
||||
ver.CreatedAt)
|
||||
if err != nil {
|
||||
// Log detail but skip — version history is non-critical
|
||||
@@ -322,8 +308,12 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (*
|
||||
|
||||
// rebuildFTSForWorkspace rebuilds the FTS index for all items in a workspace.
|
||||
// This is needed after import because direct INSERTs bypass the FTS triggers.
|
||||
// Only applicable to SQLite (PostgreSQL uses trigger-maintained tsvector columns).
|
||||
func (s *Store) rebuildFTSForWorkspace(wsID string) {
|
||||
rows, err := s.db.Query(`SELECT rowid, title, content, tags FROM items WHERE workspace_id = ? AND deleted_at IS NULL`, wsID)
|
||||
if s.dialect.Driver() != DriverSQLite {
|
||||
return
|
||||
}
|
||||
rows, err := s.db.Query(s.q(`SELECT rowid, title, content, tags FROM items WHERE workspace_id = ? AND deleted_at IS NULL`), wsID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -334,7 +324,7 @@ func (s *Store) rebuildFTSForWorkspace(wsID string) {
|
||||
if err := rows.Scan(&rowid, &title, &content, &tags); err != nil {
|
||||
continue
|
||||
}
|
||||
s.db.Exec(`INSERT INTO items_fts(rowid, title, content, tags) VALUES (?, ?, ?, ?)`, rowid, title, content, tags)
|
||||
s.db.Exec(s.q(`INSERT INTO items_fts(rowid, title, content, tags) VALUES (?, ?, ?, ?)`), rowid, title, content, tags)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+222
-132
@@ -86,18 +86,18 @@ func (s *Store) CreateItem(workspaceID, collectionID string, input models.ItemCr
|
||||
|
||||
// Assign the next item_number within this collection
|
||||
var nextNum int
|
||||
err = tx.QueryRow("SELECT COALESCE(MAX(item_number), 0) + 1 FROM items WHERE collection_id = ?", collectionID).Scan(&nextNum)
|
||||
err = tx.QueryRow(s.q("SELECT COALESCE(MAX(item_number), 0) + 1 FROM items WHERE collection_id = ?"), collectionID).Scan(&nextNum)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get next item number: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
_, err = tx.Exec(s.q(`
|
||||
INSERT INTO items (id, workspace_id, collection_id, title, slug, content, fields, tags,
|
||||
pinned, sort_order, parent_id, assigned_user_id, agent_role_id, role_sort_order,
|
||||
created_by, last_modified_by, source, item_number, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
|
||||
`, id, workspaceID, collectionID, input.Title, slug, input.Content, fields, tags,
|
||||
boolToInt(input.Pinned), input.ParentID, input.AssignedUserID, input.AgentRoleID,
|
||||
`), id, workspaceID, collectionID, input.Title, slug, input.Content, fields, tags,
|
||||
s.dialect.BoolToInt(input.Pinned), input.ParentID, input.AssignedUserID, input.AgentRoleID,
|
||||
createdBy, createdBy, source, nextNum, ts, ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert item: %w", err)
|
||||
@@ -106,10 +106,10 @@ func (s *Store) CreateItem(workspaceID, collectionID string, input models.ItemCr
|
||||
// Create initial version if there's content
|
||||
if input.Content != "" {
|
||||
vid := newID()
|
||||
_, err = tx.Exec(`
|
||||
_, err = tx.Exec(s.q(`
|
||||
INSERT INTO item_versions (id, item_id, content, change_summary, created_by, source, is_diff, created_at)
|
||||
VALUES (?, ?, ?, '', ?, ?, 0, ?)
|
||||
`, vid, id, input.Content, createdBy, source, ts)
|
||||
VALUES (?, ?, ?, '', ?, ?, ?, ?)
|
||||
`), vid, id, input.Content, createdBy, source, s.dialect.BoolToInt(false), ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create initial version: %w", err)
|
||||
}
|
||||
@@ -126,9 +126,9 @@ func (s *Store) GetItem(id string) (*models.Item, error) {
|
||||
var item models.Item
|
||||
var createdAt, updatedAt string
|
||||
var deletedAt *string
|
||||
var pinned int
|
||||
var pinned bool
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags,
|
||||
i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order,
|
||||
i.created_by, i.last_modified_by, i.source,
|
||||
@@ -141,7 +141,7 @@ func (s *Store) GetItem(id string) (*models.Item, error) {
|
||||
LEFT JOIN users au ON au.id = i.assigned_user_id
|
||||
LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id
|
||||
WHERE i.id = ? AND i.deleted_at IS NULL
|
||||
`, id).Scan(
|
||||
`), id).Scan(
|
||||
&item.ID, &item.WorkspaceID, &item.CollectionID, &item.Title, &item.Slug,
|
||||
&item.Content, &item.Fields, &item.Tags,
|
||||
&pinned, &item.SortOrder, &item.ParentID, &item.AssignedUserID, &item.AgentRoleID, &item.RoleSortOrder,
|
||||
@@ -158,7 +158,7 @@ func (s *Store) GetItem(id string) (*models.Item, error) {
|
||||
return nil, fmt.Errorf("get item: %w", err)
|
||||
}
|
||||
|
||||
item.Pinned = pinned == 1
|
||||
item.Pinned = pinned
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
item.UpdatedAt = parseTime(updatedAt)
|
||||
item.DeletedAt = parseTimePtr(deletedAt)
|
||||
@@ -168,10 +168,10 @@ func (s *Store) GetItem(id string) (*models.Item, error) {
|
||||
|
||||
func (s *Store) GetItemBySlug(workspaceID, slug string) (*models.Item, error) {
|
||||
var id string
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id FROM items
|
||||
WHERE workspace_id = ? AND slug = ? AND deleted_at IS NULL
|
||||
`, workspaceID, slug).Scan(&id)
|
||||
`), workspaceID, slug).Scan(&id)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -184,11 +184,11 @@ func (s *Store) GetItemBySlug(workspaceID, slug string) (*models.Item, error) {
|
||||
// GetItemByRef looks up an item by its PREFIX-NUMBER reference (e.g. "IDEA-15").
|
||||
func (s *Store) GetItemByRef(workspaceID, prefix string, number int) (*models.Item, error) {
|
||||
var id string
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT i.id FROM items i
|
||||
JOIN collections c ON c.id = i.collection_id
|
||||
WHERE i.workspace_id = ? AND c.prefix = ? AND i.item_number = ? AND i.deleted_at IS NULL
|
||||
`, workspaceID, prefix, number).Scan(&id)
|
||||
`), workspaceID, prefix, number).Scan(&id)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -248,9 +248,9 @@ func (s *Store) ResolveItemIncludeDeleted(workspaceID, slugOrRef string) (*model
|
||||
var item models.Item
|
||||
var createdAt, updatedAt string
|
||||
var deletedAt *string
|
||||
var pinned int
|
||||
var pinned bool
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags,
|
||||
i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order,
|
||||
i.created_by, i.last_modified_by, i.source,
|
||||
@@ -263,7 +263,7 @@ func (s *Store) ResolveItemIncludeDeleted(workspaceID, slugOrRef string) (*model
|
||||
LEFT JOIN users au ON au.id = i.assigned_user_id
|
||||
LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id
|
||||
WHERE i.workspace_id = ? AND c.prefix = ? AND i.item_number = ?
|
||||
`, workspaceID, prefix, number).Scan(
|
||||
`), workspaceID, prefix, number).Scan(
|
||||
&item.ID, &item.WorkspaceID, &item.CollectionID, &item.Title, &item.Slug,
|
||||
&item.Content, &item.Fields, &item.Tags,
|
||||
&pinned, &item.SortOrder, &item.ParentID, &item.AssignedUserID, &item.AgentRoleID, &item.RoleSortOrder,
|
||||
@@ -274,7 +274,7 @@ func (s *Store) ResolveItemIncludeDeleted(workspaceID, slugOrRef string) (*model
|
||||
&item.AgentRoleName, &item.AgentRoleSlug, &item.AgentRoleIcon,
|
||||
)
|
||||
if err == nil {
|
||||
item.Pinned = pinned == 1
|
||||
item.Pinned = pinned
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
item.UpdatedAt = parseTime(updatedAt)
|
||||
item.DeletedAt = parseTimePtr(deletedAt)
|
||||
@@ -322,9 +322,9 @@ func (s *Store) GetItemBySlugIncludeDeleted(workspaceID, slug string) (*models.I
|
||||
var item models.Item
|
||||
var createdAt, updatedAt string
|
||||
var deletedAt *string
|
||||
var pinned int
|
||||
var pinned bool
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags,
|
||||
i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order,
|
||||
i.created_by, i.last_modified_by, i.source,
|
||||
@@ -337,7 +337,7 @@ func (s *Store) GetItemBySlugIncludeDeleted(workspaceID, slug string) (*models.I
|
||||
LEFT JOIN users au ON au.id = i.assigned_user_id
|
||||
LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id
|
||||
WHERE i.workspace_id = ? AND i.slug = ?
|
||||
`, workspaceID, slug).Scan(
|
||||
`), workspaceID, slug).Scan(
|
||||
&item.ID, &item.WorkspaceID, &item.CollectionID, &item.Title, &item.Slug,
|
||||
&item.Content, &item.Fields, &item.Tags,
|
||||
&pinned, &item.SortOrder, &item.ParentID, &item.AssignedUserID, &item.AgentRoleID, &item.RoleSortOrder,
|
||||
@@ -354,7 +354,7 @@ func (s *Store) GetItemBySlugIncludeDeleted(workspaceID, slug string) (*models.I
|
||||
return nil, fmt.Errorf("get item by slug (include deleted): %w", err)
|
||||
}
|
||||
|
||||
item.Pinned = pinned == 1
|
||||
item.Pinned = pinned
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
item.UpdatedAt = parseTime(updatedAt)
|
||||
item.DeletedAt = parseTimePtr(deletedAt)
|
||||
@@ -394,8 +394,9 @@ func (s *Store) ListItems(workspaceID string, params models.ItemListParams) ([]m
|
||||
}
|
||||
|
||||
if params.Tag != "" {
|
||||
query += " AND i.tags LIKE ?"
|
||||
args = append(args, "%\""+params.Tag+"\"%")
|
||||
tagExpr, tagArg := s.dialect.JSONArrayContains("i.tags", params.Tag)
|
||||
query += " AND " + tagExpr
|
||||
args = append(args, tagArg)
|
||||
}
|
||||
|
||||
if params.ParentID != "" {
|
||||
@@ -419,25 +420,30 @@ func (s *Store) ListItems(workspaceID string, params models.ItemListParams) ([]m
|
||||
args = append(args, params.PhaseID)
|
||||
}
|
||||
|
||||
// Field filters using json_extract — supports comma-separated values as OR
|
||||
// Field filters — supports comma-separated values as OR
|
||||
for key, value := range params.Fields {
|
||||
// Sanitize the key to prevent SQL injection — field names must be
|
||||
// alphanumeric/underscore only (user-controlled from query params).
|
||||
if !isValidFieldKey(key) {
|
||||
continue
|
||||
}
|
||||
jsonExpr := s.dialect.JSONExtractText("i.fields", key)
|
||||
if strings.Contains(value, ",") {
|
||||
values := strings.Split(value, ",")
|
||||
placeholders := make([]string, len(values))
|
||||
args = append(args, "$."+key)
|
||||
for i, v := range values {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, strings.TrimSpace(v))
|
||||
}
|
||||
query += " AND json_extract(i.fields, ?) IN (" + strings.Join(placeholders, ",") + ")"
|
||||
query += " AND " + jsonExpr + " IN (" + strings.Join(placeholders, ",") + ")"
|
||||
} else {
|
||||
query += " AND json_extract(i.fields, ?) = ?"
|
||||
args = append(args, "$."+key, value)
|
||||
query += " AND " + jsonExpr + " = ?"
|
||||
args = append(args, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Sorting
|
||||
query += buildItemSort(params.Sort)
|
||||
query += buildItemSort(params.Sort, s.dialect)
|
||||
|
||||
// Pagination
|
||||
if params.Limit > 0 {
|
||||
@@ -449,7 +455,7 @@ func (s *Store) ListItems(workspaceID string, params models.ItemListParams) ([]m
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
rows, err := s.db.Query(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list items: %w", err)
|
||||
}
|
||||
@@ -459,37 +465,74 @@ func (s *Store) ListItems(workspaceID string, params models.ItemListParams) ([]m
|
||||
}
|
||||
|
||||
func (s *Store) listItemsFTS(workspaceID string, params models.ItemListParams) ([]models.Item, error) {
|
||||
query := `
|
||||
SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags,
|
||||
i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order,
|
||||
i.created_by, i.last_modified_by, i.source,
|
||||
i.item_number, i.created_at, i.updated_at,
|
||||
c.slug, c.name, c.icon, c.prefix,
|
||||
COALESCE(au.name, ''), COALESCE(au.email, ''),
|
||||
COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, '')
|
||||
FROM items i
|
||||
JOIN items_fts fts ON i.rowid = fts.rowid
|
||||
JOIN collections c ON c.id = i.collection_id
|
||||
LEFT JOIN users au ON au.id = i.assigned_user_id
|
||||
LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id
|
||||
WHERE i.workspace_id = ? AND i.deleted_at IS NULL
|
||||
AND items_fts MATCH ?
|
||||
`
|
||||
args := []interface{}{workspaceID, params.Search}
|
||||
var query string
|
||||
var args []interface{}
|
||||
var ftsRank string
|
||||
|
||||
if s.dialect.Driver() == DriverPostgres {
|
||||
// PostgreSQL: search_vector lives on the items table (aliased as "i").
|
||||
ftsMatch := s.dialect.FTSMatch("i", "search_vector")
|
||||
ftsRank = s.dialect.FTSRank("i", "search_vector")
|
||||
|
||||
query = fmt.Sprintf(`
|
||||
SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags,
|
||||
i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order,
|
||||
i.created_by, i.last_modified_by, i.source,
|
||||
i.item_number, i.created_at, i.updated_at,
|
||||
c.slug, c.name, c.icon, c.prefix,
|
||||
COALESCE(au.name, ''), COALESCE(au.email, ''),
|
||||
COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, '')
|
||||
FROM items i
|
||||
JOIN collections c ON c.id = i.collection_id
|
||||
LEFT JOIN users au ON au.id = i.assigned_user_id
|
||||
LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id
|
||||
WHERE i.workspace_id = ? AND i.deleted_at IS NULL
|
||||
AND %s
|
||||
`, ftsMatch)
|
||||
args = []interface{}{workspaceID, params.Search}
|
||||
} else {
|
||||
// SQLite: uses FTS5 virtual table "items_fts".
|
||||
ftsMatch := s.dialect.FTSMatch("items_fts", "search_vector")
|
||||
ftsRank = s.dialect.FTSRank("items_fts", "search_vector")
|
||||
|
||||
query = fmt.Sprintf(`
|
||||
SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags,
|
||||
i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order,
|
||||
i.created_by, i.last_modified_by, i.source,
|
||||
i.item_number, i.created_at, i.updated_at,
|
||||
c.slug, c.name, c.icon, c.prefix,
|
||||
COALESCE(au.name, ''), COALESCE(au.email, ''),
|
||||
COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, '')
|
||||
FROM items i
|
||||
JOIN items_fts fts ON i.rowid = fts.rowid
|
||||
JOIN collections c ON c.id = i.collection_id
|
||||
LEFT JOIN users au ON au.id = i.assigned_user_id
|
||||
LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id
|
||||
WHERE i.workspace_id = ? AND i.deleted_at IS NULL
|
||||
AND %s
|
||||
`, ftsMatch)
|
||||
args = []interface{}{workspaceID, params.Search}
|
||||
}
|
||||
|
||||
if params.CollectionSlug != "" {
|
||||
query += " AND c.slug = ?"
|
||||
args = append(args, params.CollectionSlug)
|
||||
}
|
||||
|
||||
query += " ORDER BY rank"
|
||||
// SQLite bm25(): more negative = more relevant → ASC (default).
|
||||
// PostgreSQL ts_rank(): higher = more relevant → DESC.
|
||||
if s.dialect.Driver() == DriverPostgres {
|
||||
query += " ORDER BY " + ftsRank + " DESC"
|
||||
} else {
|
||||
query += " ORDER BY " + ftsRank
|
||||
}
|
||||
|
||||
if params.Limit > 0 {
|
||||
query += " LIMIT ?"
|
||||
args = append(args, params.Limit)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
rows, err := s.db.Query(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search items: %w", err)
|
||||
}
|
||||
@@ -543,17 +586,17 @@ func (s *Store) UpdateItem(id string, input models.ItemUpdate) (*models.Item, er
|
||||
if shouldVersion {
|
||||
vid := newID()
|
||||
versionContent := existing.Content
|
||||
isDiff := 0
|
||||
isDiff := false
|
||||
patch := diff.CreateReversePatch(existing.Content, *input.Content)
|
||||
if diff.IsDiffSmaller(patch, existing.Content) {
|
||||
versionContent = patch
|
||||
isDiff = 1
|
||||
isDiff = true
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
_, err = tx.Exec(s.q(`
|
||||
INSERT INTO item_versions (id, item_id, content, change_summary, created_by, source, is_diff, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, vid, id, versionContent, input.ChangeSummary, createdBy, source, isDiff, ts)
|
||||
`), vid, id, versionContent, input.ChangeSummary, createdBy, source, s.dialect.BoolToInt(isDiff), ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create version: %w", err)
|
||||
}
|
||||
@@ -592,7 +635,7 @@ func (s *Store) UpdateItem(id string, input models.ItemUpdate) (*models.Item, er
|
||||
}
|
||||
if input.Pinned != nil {
|
||||
sets = append(sets, "pinned = ?")
|
||||
args = append(args, boolToInt(*input.Pinned))
|
||||
args = append(args, s.dialect.BoolToInt(*input.Pinned))
|
||||
}
|
||||
if input.SortOrder != nil {
|
||||
sets = append(sets, "sort_order = ?")
|
||||
@@ -625,7 +668,7 @@ func (s *Store) UpdateItem(id string, input models.ItemUpdate) (*models.Item, er
|
||||
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf("UPDATE items SET %s WHERE id = ?", strings.Join(sets, ", "))
|
||||
_, err = tx.Exec(query, args...)
|
||||
_, err = tx.Exec(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update item: %w", err)
|
||||
}
|
||||
@@ -639,10 +682,10 @@ func (s *Store) UpdateItem(id string, input models.ItemUpdate) (*models.Item, er
|
||||
|
||||
func (s *Store) DeleteItem(id string) error {
|
||||
ts := now()
|
||||
result, err := s.db.Exec(`
|
||||
result, err := s.db.Exec(s.q(`
|
||||
UPDATE items SET deleted_at = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`, ts, ts, id)
|
||||
`), ts, ts, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete item: %w", err)
|
||||
}
|
||||
@@ -655,10 +698,10 @@ func (s *Store) DeleteItem(id string) error {
|
||||
|
||||
func (s *Store) RestoreItem(id string) (*models.Item, error) {
|
||||
ts := now()
|
||||
result, err := s.db.Exec(`
|
||||
result, err := s.db.Exec(s.q(`
|
||||
UPDATE items SET deleted_at = NULL, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NOT NULL
|
||||
`, ts, id)
|
||||
`), ts, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("restore item: %w", err)
|
||||
}
|
||||
@@ -670,34 +713,73 @@ func (s *Store) RestoreItem(id string) (*models.Item, error) {
|
||||
}
|
||||
|
||||
func (s *Store) SearchItems(workspaceID, query string) ([]ItemSearchResult, error) {
|
||||
sqlQuery := `
|
||||
SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags,
|
||||
i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order,
|
||||
i.created_by, i.last_modified_by, i.source,
|
||||
i.item_number, i.created_at, i.updated_at,
|
||||
c.slug, c.name, c.icon, c.prefix,
|
||||
COALESCE(au.name, ''), COALESCE(au.email, ''),
|
||||
COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, ''),
|
||||
snippet(items_fts, 1, '<mark>', '</mark>', '...', 32) as snippet,
|
||||
rank
|
||||
FROM items_fts fts
|
||||
JOIN items i ON i.rowid = fts.rowid
|
||||
JOIN collections c ON c.id = i.collection_id
|
||||
LEFT JOIN users au ON au.id = i.assigned_user_id
|
||||
LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id
|
||||
WHERE items_fts MATCH ?
|
||||
AND i.deleted_at IS NULL
|
||||
`
|
||||
args := []interface{}{query}
|
||||
var sqlQuery string
|
||||
var args []interface{}
|
||||
|
||||
if s.dialect.Driver() == DriverPostgres {
|
||||
// PostgreSQL: search_vector lives on the items table (aliased as "i").
|
||||
ftsSnippet := s.dialect.FTSSnippet("i", 1, "i.content")
|
||||
ftsMatch := s.dialect.FTSMatch("i", "search_vector")
|
||||
ftsRank := s.dialect.FTSRank("i", "search_vector")
|
||||
|
||||
sqlQuery = fmt.Sprintf(`
|
||||
SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags,
|
||||
i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order,
|
||||
i.created_by, i.last_modified_by, i.source,
|
||||
i.item_number, i.created_at, i.updated_at,
|
||||
c.slug, c.name, c.icon, c.prefix,
|
||||
COALESCE(au.name, ''), COALESCE(au.email, ''),
|
||||
COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, ''),
|
||||
%s as snippet,
|
||||
%s as rank_score
|
||||
FROM items i
|
||||
JOIN collections c ON c.id = i.collection_id
|
||||
LEFT JOIN users au ON au.id = i.assigned_user_id
|
||||
LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id
|
||||
WHERE %s
|
||||
AND i.deleted_at IS NULL
|
||||
`, ftsSnippet, ftsRank, ftsMatch)
|
||||
// PostgreSQL: FTSSnippet, FTSRank, and FTSMatch each consume a "?" for plainto_tsquery
|
||||
args = []interface{}{query, query, query}
|
||||
} else {
|
||||
// SQLite: uses FTS5 virtual table "items_fts".
|
||||
ftsSnippet := s.dialect.FTSSnippet("items_fts", 1, "i.content")
|
||||
ftsMatch := s.dialect.FTSMatch("items_fts", "search_vector")
|
||||
ftsRank := s.dialect.FTSRank("items_fts", "search_vector")
|
||||
|
||||
sqlQuery = fmt.Sprintf(`
|
||||
SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags,
|
||||
i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order,
|
||||
i.created_by, i.last_modified_by, i.source,
|
||||
i.item_number, i.created_at, i.updated_at,
|
||||
c.slug, c.name, c.icon, c.prefix,
|
||||
COALESCE(au.name, ''), COALESCE(au.email, ''),
|
||||
COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, ''),
|
||||
%s as snippet,
|
||||
%s as rank_score
|
||||
FROM items_fts fts
|
||||
JOIN items i ON i.rowid = fts.rowid
|
||||
JOIN collections c ON c.id = i.collection_id
|
||||
LEFT JOIN users au ON au.id = i.assigned_user_id
|
||||
LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id
|
||||
WHERE %s
|
||||
AND i.deleted_at IS NULL
|
||||
`, ftsSnippet, ftsRank, ftsMatch)
|
||||
args = []interface{}{query}
|
||||
}
|
||||
|
||||
if workspaceID != "" {
|
||||
sqlQuery += " AND i.workspace_id = ?"
|
||||
args = append(args, workspaceID)
|
||||
}
|
||||
|
||||
sqlQuery += " ORDER BY rank LIMIT 50"
|
||||
if s.dialect.Driver() == DriverPostgres {
|
||||
sqlQuery += " ORDER BY rank_score DESC LIMIT 50"
|
||||
} else {
|
||||
sqlQuery += " ORDER BY rank_score LIMIT 50"
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(sqlQuery, args...)
|
||||
rows, err := s.db.Query(s.q(sqlQuery), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search items: %w", err)
|
||||
}
|
||||
@@ -707,7 +789,7 @@ func (s *Store) SearchItems(workspaceID, query string) ([]ItemSearchResult, erro
|
||||
for rows.Next() {
|
||||
var r ItemSearchResult
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
var pinned bool
|
||||
if err := rows.Scan(
|
||||
&r.Item.ID, &r.Item.WorkspaceID, &r.Item.CollectionID, &r.Item.Title, &r.Item.Slug,
|
||||
&r.Item.Content, &r.Item.Fields, &r.Item.Tags,
|
||||
@@ -721,7 +803,7 @@ func (s *Store) SearchItems(workspaceID, query string) ([]ItemSearchResult, erro
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Item.Pinned = pinned == 1
|
||||
r.Item.Pinned = pinned
|
||||
r.Item.CreatedAt = parseTime(createdAt)
|
||||
r.Item.UpdatedAt = parseTime(updatedAt)
|
||||
r.Item.ComputeRef()
|
||||
@@ -749,10 +831,10 @@ func (s *Store) CreateItemLink(workspaceID string, input models.ItemLinkCreate,
|
||||
createdBy = "user"
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
_, err = s.db.Exec(s.q(`
|
||||
INSERT INTO item_links (id, workspace_id, source_id, target_id, link_type, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, id, workspaceID, sourceID, input.TargetID, linkType, createdBy, ts)
|
||||
`), id, workspaceID, sourceID, input.TargetID, linkType, createdBy, ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create item link: %w", err)
|
||||
}
|
||||
@@ -768,18 +850,20 @@ func (s *Store) getItemLink(id string) (*models.ItemLink, error) {
|
||||
var sourceItemNumber, targetItemNumber sql.NullInt64
|
||||
var sourceStatus, targetStatus sql.NullString
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
srcStatus := s.dialect.JSONExtractText("s.fields", "status")
|
||||
tgtStatus := s.dialect.JSONExtractText("t.fields", "status")
|
||||
err := s.db.QueryRow(s.q(fmt.Sprintf(`
|
||||
SELECT l.id, l.workspace_id, l.source_id, l.target_id, l.link_type, l.created_by, l.created_at,
|
||||
s.title, t.title, s.slug, t.slug, sc.slug, tc.slug, sc.prefix, tc.prefix,
|
||||
s.item_number, t.item_number,
|
||||
json_extract(s.fields, '$.status'), json_extract(t.fields, '$.status')
|
||||
%s, %s
|
||||
FROM item_links l
|
||||
JOIN items s ON s.id = l.source_id
|
||||
JOIN items t ON t.id = l.target_id
|
||||
JOIN collections sc ON sc.id = s.collection_id
|
||||
JOIN collections tc ON tc.id = t.collection_id
|
||||
WHERE l.id = ?
|
||||
`, id).Scan(
|
||||
`, srcStatus, tgtStatus)), id).Scan(
|
||||
&link.ID, &link.WorkspaceID, &link.SourceID, &link.TargetID,
|
||||
&link.LinkType, &link.CreatedBy, &createdAt,
|
||||
&link.SourceTitle, &link.TargetTitle,
|
||||
@@ -812,11 +896,13 @@ func (s *Store) getItemLink(id string) (*models.ItemLink, error) {
|
||||
}
|
||||
|
||||
func (s *Store) GetItemLinks(itemID string) ([]models.ItemLink, error) {
|
||||
rows, err := s.db.Query(`
|
||||
srcStatusExpr := s.dialect.JSONExtractText("s.fields", "status")
|
||||
tgtStatusExpr := s.dialect.JSONExtractText("t.fields", "status")
|
||||
rows, err := s.db.Query(s.q(fmt.Sprintf(`
|
||||
SELECT l.id, l.workspace_id, l.source_id, l.target_id, l.link_type, l.created_by, l.created_at,
|
||||
s.title, t.title, s.slug, t.slug, sc.slug, tc.slug, sc.prefix, tc.prefix,
|
||||
s.item_number, t.item_number,
|
||||
json_extract(s.fields, '$.status'), json_extract(t.fields, '$.status')
|
||||
%s, %s
|
||||
FROM item_links l
|
||||
JOIN items s ON s.id = l.source_id
|
||||
JOIN items t ON t.id = l.target_id
|
||||
@@ -824,7 +910,7 @@ func (s *Store) GetItemLinks(itemID string) ([]models.ItemLink, error) {
|
||||
JOIN collections tc ON tc.id = t.collection_id
|
||||
WHERE l.source_id = ? OR l.target_id = ?
|
||||
ORDER BY l.created_at DESC
|
||||
`, itemID, itemID)
|
||||
`, srcStatusExpr, tgtStatusExpr)), itemID, itemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get item links: %w", err)
|
||||
}
|
||||
@@ -868,7 +954,7 @@ func (s *Store) GetItemLinks(itemID string) ([]models.ItemLink, error) {
|
||||
}
|
||||
|
||||
func (s *Store) DeleteItemLink(id string) error {
|
||||
result, err := s.db.Exec("DELETE FROM item_links WHERE id = ?", id)
|
||||
result, err := s.db.Exec(s.q("DELETE FROM item_links WHERE id = ?"), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete item link: %w", err)
|
||||
}
|
||||
@@ -891,17 +977,17 @@ func (s *Store) SetPhaseLink(workspaceID, itemID, phaseID, createdBy string) (*m
|
||||
defer tx.Rollback()
|
||||
|
||||
// Delete existing phase link for this item (if any)
|
||||
if _, err := tx.Exec(`DELETE FROM item_links WHERE source_id = ? AND link_type = 'phase'`, itemID); err != nil {
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM item_links WHERE source_id = ? AND link_type = 'phase'`), itemID); err != nil {
|
||||
return nil, fmt.Errorf("delete existing phase link: %w", err)
|
||||
}
|
||||
|
||||
// Insert new phase link
|
||||
id := newID()
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := tx.Exec(`
|
||||
if _, err := tx.Exec(s.q(`
|
||||
INSERT INTO item_links (id, workspace_id, source_id, target_id, link_type, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, 'phase', ?, ?)
|
||||
`, id, workspaceID, itemID, phaseID, createdBy, now); err != nil {
|
||||
`), id, workspaceID, itemID, phaseID, createdBy, now); err != nil {
|
||||
return nil, fmt.Errorf("insert phase link: %w", err)
|
||||
}
|
||||
|
||||
@@ -924,7 +1010,7 @@ func (s *Store) SetPhaseLink(workspaceID, itemID, phaseID, createdBy string) (*m
|
||||
|
||||
// ClearPhaseLink removes the phase link for an item.
|
||||
func (s *Store) ClearPhaseLink(itemID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM item_links WHERE source_id = ? AND link_type = 'phase'`, itemID)
|
||||
_, err := s.db.Exec(s.q(`DELETE FROM item_links WHERE source_id = ? AND link_type = 'phase'`), itemID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear phase link: %w", err)
|
||||
}
|
||||
@@ -933,18 +1019,20 @@ func (s *Store) ClearPhaseLink(itemID string) error {
|
||||
|
||||
// GetPhaseForItem returns the phase link for an item, or nil if not in a phase.
|
||||
func (s *Store) GetPhaseForItem(itemID string) (*models.ItemLink, error) {
|
||||
rows, err := s.db.Query(`
|
||||
sStatusExpr := s.dialect.JSONExtractText("s.fields", "status")
|
||||
tStatusExpr := s.dialect.JSONExtractText("t.fields", "status")
|
||||
rows, err := s.db.Query(s.q(fmt.Sprintf(`
|
||||
SELECT l.id, l.workspace_id, l.source_id, l.target_id, l.link_type, l.created_by, l.created_at,
|
||||
s.title, t.title, s.slug, t.slug, sc.slug, tc.slug, sc.prefix, tc.prefix,
|
||||
s.item_number, t.item_number,
|
||||
json_extract(s.fields, '$.status'), json_extract(t.fields, '$.status')
|
||||
%s, %s
|
||||
FROM item_links l
|
||||
JOIN items s ON s.id = l.source_id
|
||||
JOIN items t ON t.id = l.target_id
|
||||
JOIN collections sc ON sc.id = s.collection_id
|
||||
JOIN collections tc ON tc.id = t.collection_id
|
||||
WHERE l.source_id = ? AND l.link_type = 'phase'
|
||||
`, itemID)
|
||||
`, sStatusExpr, tStatusExpr)), itemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get phase for item: %w", err)
|
||||
}
|
||||
@@ -990,10 +1078,10 @@ func (s *Store) GetPhaseForItem(itemID string) (*models.ItemLink, error) {
|
||||
// GetTaskPhaseMap returns a map of item ID -> phase item ID for all phase links
|
||||
// in a workspace. Used for efficient batch lookups (e.g., dashboard).
|
||||
func (s *Store) GetTaskPhaseMap(workspaceID string) (map[string]string, error) {
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT source_id, target_id FROM item_links
|
||||
WHERE workspace_id = ? AND link_type = 'phase'
|
||||
`, workspaceID)
|
||||
`), workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get task phase map: %w", err)
|
||||
}
|
||||
@@ -1017,15 +1105,16 @@ func (s *Store) GetTaskPhaseMap(workspaceID string) (map[string]string, error) {
|
||||
func (s *Store) GetPhaseProgress(phaseItemID string) (total int, done int, err error) {
|
||||
termPlaceholders, termArgs := s.getTasksTerminalPlaceholders()
|
||||
args := append(termArgs, phaseItemID)
|
||||
err = s.db.QueryRow(`
|
||||
statusExpr := s.dialect.JSONExtractText("i.fields", "status")
|
||||
err = s.db.QueryRow(s.q(fmt.Sprintf(`
|
||||
SELECT COUNT(*),
|
||||
COUNT(CASE WHEN LOWER(json_extract(i.fields, '$.status')) IN (`+termPlaceholders+`) THEN 1 END)
|
||||
COUNT(CASE WHEN LOWER(%s) IN (%s) THEN 1 END)
|
||||
FROM items i
|
||||
JOIN collections c ON c.id = i.collection_id
|
||||
JOIN item_links il ON il.source_id = i.id AND il.link_type = 'phase' AND il.target_id = ?
|
||||
WHERE c.slug = 'tasks'
|
||||
AND i.deleted_at IS NULL
|
||||
`, args...).Scan(&total, &done)
|
||||
`, statusExpr, termPlaceholders)), args...).Scan(&total, &done)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("get phase progress: %w", err)
|
||||
}
|
||||
@@ -1038,7 +1127,7 @@ func (s *Store) GetPhaseProgress(phaseItemID string) (total int, done int, err e
|
||||
func (s *Store) getTasksTerminalPlaceholders() (string, []any) {
|
||||
// Try to find the tasks collection schema in any workspace
|
||||
var schemaJSON sql.NullString
|
||||
_ = s.db.QueryRow(`SELECT schema FROM collections WHERE slug = 'tasks' AND deleted_at IS NULL LIMIT 1`).Scan(&schemaJSON)
|
||||
_ = s.db.QueryRow(s.q(`SELECT schema FROM collections WHERE slug = 'tasks' AND deleted_at IS NULL LIMIT 1`)).Scan(&schemaJSON)
|
||||
if schemaJSON.Valid {
|
||||
var schema models.CollectionSchema
|
||||
if err := json.Unmarshal([]byte(schemaJSON.String), &schema); err == nil {
|
||||
@@ -1059,10 +1148,11 @@ type PhaseProgress struct {
|
||||
func (s *Store) GetAllPhasesProgress(workspaceID string) ([]PhaseProgress, error) {
|
||||
termPlaceholders, termArgs := s.getTasksTerminalPlaceholders()
|
||||
args := append(termArgs, workspaceID)
|
||||
rows, err := s.db.Query(`
|
||||
tStatusExpr2 := s.dialect.JSONExtractText("t.fields", "status")
|
||||
rows, err := s.db.Query(s.q(fmt.Sprintf(`
|
||||
SELECT p.id,
|
||||
COUNT(t.id),
|
||||
COUNT(CASE WHEN LOWER(json_extract(t.fields, '$.status')) IN (`+termPlaceholders+`) THEN 1 END)
|
||||
COUNT(CASE WHEN LOWER(%s) IN (%s) THEN 1 END)
|
||||
FROM items p
|
||||
JOIN collections pc ON pc.id = p.collection_id AND pc.slug = 'phases'
|
||||
LEFT JOIN item_links il ON il.link_type = 'phase' AND il.target_id = p.id
|
||||
@@ -1072,7 +1162,7 @@ func (s *Store) GetAllPhasesProgress(workspaceID string) ([]PhaseProgress, error
|
||||
WHERE p.workspace_id = ?
|
||||
AND p.deleted_at IS NULL
|
||||
GROUP BY p.id
|
||||
`, args...)
|
||||
`, tStatusExpr2, termPlaceholders)), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get all phases progress: %w", err)
|
||||
}
|
||||
@@ -1094,7 +1184,7 @@ func (s *Store) GetAllPhasesProgress(workspaceID string) ([]PhaseProgress, error
|
||||
|
||||
// GetTasksForPhase returns all non-deleted tasks linked to the given phase via item_links.
|
||||
func (s *Store) GetTasksForPhase(phaseItemID string) ([]models.Item, error) {
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags,
|
||||
i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order,
|
||||
i.created_by, i.last_modified_by, i.source,
|
||||
@@ -1110,7 +1200,7 @@ func (s *Store) GetTasksForPhase(phaseItemID string) ([]models.Item, error) {
|
||||
WHERE c.slug = 'tasks'
|
||||
AND i.deleted_at IS NULL
|
||||
ORDER BY i.sort_order ASC, i.created_at ASC
|
||||
`, phaseItemID)
|
||||
`), phaseItemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get tasks for phase: %w", err)
|
||||
}
|
||||
@@ -1131,16 +1221,16 @@ func (s *Store) MoveItem(itemID, targetCollectionID, newFieldsJSON string) (*mod
|
||||
|
||||
// Get next item_number in the target collection
|
||||
var nextNumber int
|
||||
err = tx.QueryRow(`SELECT COALESCE(MAX(item_number), 0) + 1 FROM items WHERE collection_id = ?`, targetCollectionID).Scan(&nextNumber)
|
||||
err = tx.QueryRow(s.q(`SELECT COALESCE(MAX(item_number), 0) + 1 FROM items WHERE collection_id = ?`), targetCollectionID).Scan(&nextNumber)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get next item number: %w", err)
|
||||
}
|
||||
|
||||
// Update the item
|
||||
_, err = tx.Exec(`
|
||||
_, err = tx.Exec(s.q(`
|
||||
UPDATE items
|
||||
SET collection_id = ?, fields = ?, item_number = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL`,
|
||||
WHERE id = ? AND deleted_at IS NULL`),
|
||||
targetCollectionID, newFieldsJSON, nextNumber, time.Now().UTC().Format(time.RFC3339), itemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("move item: %w", err)
|
||||
@@ -1158,15 +1248,15 @@ func (s *Store) MoveItem(itemID, targetCollectionID, newFieldsJSON string) (*mod
|
||||
// validSortField matches safe field names (alphanumeric + underscore, starting with a letter).
|
||||
var validSortField = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`)
|
||||
|
||||
func buildItemSort(sort string) string {
|
||||
func buildItemSort(sort string, dialect Dialect) string {
|
||||
if sort == "" {
|
||||
return " ORDER BY i.pinned DESC, i.updated_at DESC"
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, s := range strings.Split(sort, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
tokens := strings.SplitN(s, ":", 2)
|
||||
for _, seg := range strings.Split(sort, ",") {
|
||||
seg = strings.TrimSpace(seg)
|
||||
tokens := strings.SplitN(seg, ":", 2)
|
||||
col := tokens[0]
|
||||
dir := "ASC"
|
||||
if len(tokens) == 2 && strings.ToUpper(tokens[1]) == "DESC" {
|
||||
@@ -1183,12 +1273,12 @@ func buildItemSort(sort string) string {
|
||||
case "sort_order":
|
||||
parts = append(parts, fmt.Sprintf("i.sort_order %s", dir))
|
||||
default:
|
||||
// For field-based sorting, use json_extract — validate the field name
|
||||
// For field-based sorting, use dialect JSON extract — validate the field name
|
||||
// to prevent SQL injection via crafted sort parameters.
|
||||
if !validSortField.MatchString(col) {
|
||||
continue // skip invalid field names
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("json_extract(i.fields, '$.%s') %s", col, dir))
|
||||
parts = append(parts, fmt.Sprintf("%s %s", dialect.JSONExtractText("i.fields", col), dir))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1201,13 +1291,13 @@ func buildItemSort(sort string) string {
|
||||
// shouldCreateItemVersion mirrors ShouldCreateVersion but queries item_versions.
|
||||
func (s *Store) shouldCreateItemVersion(itemID, actor, source string) (bool, error) {
|
||||
var createdBy, src, createdAt string
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT created_by, source, created_at
|
||||
FROM item_versions
|
||||
WHERE item_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, itemID).Scan(&createdBy, &src, &createdAt)
|
||||
`), itemID).Scan(&createdBy, &src, &createdAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return true, nil // No versions yet
|
||||
}
|
||||
@@ -1257,13 +1347,13 @@ func (s *Store) ListItemVersionsResolved(itemID, currentContent string) ([]model
|
||||
// ordered newest-first, limited to `limit` results. Used for cursor-based timeline pagination.
|
||||
func (s *Store) ListItemVersionsBeforeTime(itemID string, before time.Time, beforeID string, limit int) ([]models.Version, error) {
|
||||
ts := before.Format(time.RFC3339)
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT id, item_id, content, change_summary, created_by, source, is_diff, created_at
|
||||
FROM item_versions
|
||||
WHERE item_id = ? AND (created_at < ? OR (created_at = ? AND id < ?))
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?
|
||||
`, itemID, ts, ts, beforeID, limit)
|
||||
`), itemID, ts, ts, beforeID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1273,11 +1363,11 @@ func (s *Store) ListItemVersionsBeforeTime(itemID string, before time.Time, befo
|
||||
for rows.Next() {
|
||||
var v models.Version
|
||||
var createdAt string
|
||||
var isDiff int
|
||||
var isDiff bool
|
||||
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Content, &v.ChangeSummary, &v.CreatedBy, &v.Source, &isDiff, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.IsDiff = isDiff == 1
|
||||
v.IsDiff = isDiff
|
||||
v.CreatedAt = parseTime(createdAt)
|
||||
versions = append(versions, v)
|
||||
}
|
||||
@@ -1286,12 +1376,12 @@ func (s *Store) ListItemVersionsBeforeTime(itemID string, before time.Time, befo
|
||||
|
||||
// ListItemVersions returns all versions for an item.
|
||||
func (s *Store) ListItemVersions(itemID string) ([]models.Version, error) {
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT id, item_id, content, change_summary, created_by, source, is_diff, created_at
|
||||
FROM item_versions
|
||||
WHERE item_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`, itemID)
|
||||
`), itemID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1301,11 +1391,11 @@ func (s *Store) ListItemVersions(itemID string) ([]models.Version, error) {
|
||||
for rows.Next() {
|
||||
var v models.Version
|
||||
var createdAt string
|
||||
var isDiff int
|
||||
var isDiff bool
|
||||
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Content, &v.ChangeSummary, &v.CreatedBy, &v.Source, &isDiff, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.IsDiff = isDiff == 1
|
||||
v.IsDiff = isDiff
|
||||
v.CreatedAt = parseTime(createdAt)
|
||||
versions = append(versions, v)
|
||||
}
|
||||
@@ -1317,7 +1407,7 @@ func scanItems(rows *sql.Rows) ([]models.Item, error) {
|
||||
for rows.Next() {
|
||||
var item models.Item
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
var pinned bool
|
||||
if err := rows.Scan(
|
||||
&item.ID, &item.WorkspaceID, &item.CollectionID, &item.Title, &item.Slug,
|
||||
&item.Content, &item.Fields, &item.Tags,
|
||||
@@ -1330,7 +1420,7 @@ func scanItems(rows *sql.Rows) ([]models.Item, error) {
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Pinned = pinned == 1
|
||||
item.Pinned = pinned
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
item.UpdatedAt = parseTime(updatedAt)
|
||||
hydrateItemComputedMetadata(&item)
|
||||
|
||||
@@ -18,9 +18,9 @@ const resetTokenTTL = 1 * time.Hour
|
||||
// stored as a SHA-256 hash — the plaintext cannot be recovered.
|
||||
func (s *Store) CreatePasswordReset(userID string) (string, error) {
|
||||
// Invalidate any existing unused tokens for this user
|
||||
_, _ = s.db.Exec(`
|
||||
_, _ = s.db.Exec(s.q(`
|
||||
UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL
|
||||
`, now(), userID)
|
||||
`), now(), userID)
|
||||
|
||||
// Generate token
|
||||
raw := make([]byte, 32)
|
||||
@@ -33,10 +33,10 @@ func (s *Store) CreatePasswordReset(userID string) (string, error) {
|
||||
|
||||
expiresAt := time.Now().UTC().Add(resetTokenTTL).Format(time.RFC3339)
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
_, err := s.db.Exec(s.q(`
|
||||
INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, newID(), userID, tokenHash, expiresAt, now())
|
||||
`), newID(), userID, tokenHash, expiresAt, now())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("insert reset token: %w", err)
|
||||
}
|
||||
@@ -57,12 +57,12 @@ func (s *Store) ConsumePasswordReset(token string) (*models.User, error) {
|
||||
// currently unused and not expired. The WHERE clause ensures only
|
||||
// one concurrent caller can succeed.
|
||||
var userID string
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
UPDATE password_reset_tokens
|
||||
SET used_at = ?
|
||||
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?
|
||||
RETURNING user_id
|
||||
`, now(), tokenHash, now()).Scan(&userID)
|
||||
`), now(), tokenHash, now()).Scan(&userID)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil // Invalid, expired, or already used
|
||||
@@ -82,8 +82,8 @@ func (s *Store) ConsumePasswordReset(token string) (*models.User, error) {
|
||||
|
||||
// CleanExpiredPasswordResets removes old reset tokens.
|
||||
func (s *Store) CleanExpiredPasswordResets() error {
|
||||
_, err := s.db.Exec(`
|
||||
_, err := s.db.Exec(s.q(`
|
||||
DELETE FROM password_reset_tokens WHERE expires_at < ? OR used_at IS NOT NULL
|
||||
`, now())
|
||||
`), now())
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
-- Pad PostgreSQL schema (consolidated from SQLite migrations 001-021)
|
||||
-- This is the initial schema for PostgreSQL deployments.
|
||||
|
||||
-- Enable UUID generation
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- ========== Core tables ==========
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
settings JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
doc_type TEXT NOT NULL DEFAULT 'notes'
|
||||
CHECK (doc_type IN ('roadmap','phase-plan','architecture','ideation',
|
||||
'feature-spec','notes','prompt-library','reference')),
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft','active','completed','archived')),
|
||||
tags JSONB NOT NULL DEFAULT '[]',
|
||||
pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_by TEXT NOT NULL DEFAULT 'user'
|
||||
CHECK (created_by IN ('user','agent')),
|
||||
last_modified_by TEXT NOT NULL DEFAULT 'user'
|
||||
CHECK (last_modified_by IN ('user','agent')),
|
||||
source TEXT NOT NULL DEFAULT 'web'
|
||||
CHECK (source IN ('cli','web','skill')),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT,
|
||||
|
||||
-- Full-text search vector (auto-updated via trigger)
|
||||
search_vector TSVECTOR,
|
||||
|
||||
UNIQUE(workspace_id, slug),
|
||||
UNIQUE(workspace_id, title)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_workspace ON documents(workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_type ON documents(workspace_id, doc_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(workspace_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_updated ON documents(workspace_id, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_fts ON documents USING GIN(search_vector);
|
||||
|
||||
-- Trigger to maintain document search vector
|
||||
CREATE OR REPLACE FUNCTION documents_search_vector_update() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', COALESCE(NEW.content, '')), 'B') ||
|
||||
setweight(to_tsvector('english', COALESCE(NEW.tags::text, '')), 'C');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER documents_search_vector_trigger
|
||||
BEFORE INSERT OR UPDATE OF title, content, tags ON documents
|
||||
FOR EACH ROW EXECUTE FUNCTION documents_search_vector_update();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL REFERENCES documents(id),
|
||||
content TEXT NOT NULL,
|
||||
change_summary TEXT NOT NULL DEFAULT '',
|
||||
is_diff BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_by TEXT NOT NULL DEFAULT 'user'
|
||||
CHECK (created_by IN ('user','agent')),
|
||||
source TEXT NOT NULL DEFAULT 'web'
|
||||
CHECK (source IN ('cli','web','skill')),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_versions_document ON versions(document_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
||||
document_id TEXT,
|
||||
action TEXT NOT NULL
|
||||
CHECK (action IN ('created','updated','archived',
|
||||
'restored','read','searched')),
|
||||
actor TEXT NOT NULL CHECK (actor IN ('user','agent')),
|
||||
source TEXT NOT NULL CHECK (source IN ('cli','web','skill')),
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
user_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_workspace ON activities(workspace_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_document ON activities(document_id, created_at);
|
||||
|
||||
-- ========== Collections & Items ==========
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collections (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
icon TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
prefix TEXT NOT NULL DEFAULT '',
|
||||
schema JSONB NOT NULL DEFAULT '{"fields":[]}',
|
||||
settings JSONB DEFAULT '{}',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT,
|
||||
UNIQUE(workspace_id, slug)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
||||
collection_id TEXT NOT NULL REFERENCES collections(id),
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
content TEXT DEFAULT '',
|
||||
fields JSONB DEFAULT '{}',
|
||||
tags JSONB DEFAULT '[]',
|
||||
pinned BOOLEAN DEFAULT FALSE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
item_number INTEGER,
|
||||
parent_id TEXT REFERENCES items(id),
|
||||
created_by TEXT DEFAULT 'user',
|
||||
last_modified_by TEXT DEFAULT 'user',
|
||||
source TEXT DEFAULT 'web',
|
||||
created_by_user_id TEXT,
|
||||
last_modified_by_user_id TEXT,
|
||||
assigned_user_id TEXT,
|
||||
agent_role_id TEXT,
|
||||
role_sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT,
|
||||
|
||||
-- Full-text search vector
|
||||
search_vector TSVECTOR,
|
||||
|
||||
UNIQUE(workspace_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_items_collection ON items(collection_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_items_workspace ON items(workspace_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_items_updated ON items(updated_at) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_items_assigned_user ON items(assigned_user_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_items_agent_role ON items(agent_role_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_items_fts ON items USING GIN(search_vector);
|
||||
|
||||
-- Trigger to maintain item search vector
|
||||
CREATE OR REPLACE FUNCTION items_search_vector_update() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', COALESCE(NEW.content, '')), 'B') ||
|
||||
setweight(to_tsvector('english', COALESCE(NEW.tags::text, '')), 'C');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER items_search_vector_trigger
|
||||
BEFORE INSERT OR UPDATE OF title, content, tags ON items
|
||||
FOR EACH ROW EXECUTE FUNCTION items_search_vector_update();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL REFERENCES items(id),
|
||||
target_id TEXT NOT NULL REFERENCES items(id),
|
||||
link_type TEXT DEFAULT 'related',
|
||||
created_by TEXT DEFAULT 'user',
|
||||
user_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(source_id, target_id, link_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_links_source ON item_links(source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_links_target ON item_links(target_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS views (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
||||
collection_id TEXT REFERENCES collections(id),
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
view_type TEXT NOT NULL,
|
||||
config JSONB DEFAULT '{}',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(workspace_id, slug)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
item_id TEXT NOT NULL REFERENCES items(id),
|
||||
content TEXT NOT NULL,
|
||||
change_summary TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL DEFAULT 'user',
|
||||
source TEXT NOT NULL DEFAULT 'web',
|
||||
is_diff BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
user_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_item_versions_item ON item_versions(item_id, created_at);
|
||||
|
||||
-- ========== Comments & Reactions ==========
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
item_id TEXT NOT NULL REFERENCES items(id),
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
activity_id TEXT,
|
||||
parent_id TEXT REFERENCES comments(id),
|
||||
created_by TEXT NOT NULL DEFAULT 'user'
|
||||
CHECK (created_by IN ('user', 'agent')),
|
||||
source TEXT NOT NULL DEFAULT 'web'
|
||||
CHECK (source IN ('cli', 'web', 'skill')),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
|
||||
-- Full-text search vector
|
||||
search_vector TSVECTOR
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_item ON comments(item_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_workspace ON comments(workspace_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_activity ON comments(activity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_fts ON comments USING GIN(search_vector);
|
||||
|
||||
-- Trigger to maintain comment search vector
|
||||
CREATE OR REPLACE FUNCTION comments_search_vector_update() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
to_tsvector('english', COALESCE(NEW.body, ''));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER comments_search_vector_trigger
|
||||
BEFORE INSERT OR UPDATE OF body ON comments
|
||||
FOR EACH ROW EXECUTE FUNCTION comments_search_vector_update();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comment_reactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
comment_id TEXT NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
|
||||
user_id TEXT,
|
||||
actor TEXT NOT NULL DEFAULT 'user',
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(comment_id, user_id, emoji)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_comment_reactions_comment ON comment_reactions(comment_id);
|
||||
|
||||
-- ========== Users & Auth ==========
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK (role IN ('admin', 'member')),
|
||||
avatar_url TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
token_hash TEXT NOT NULL,
|
||||
device_info TEXT DEFAULT '',
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_members (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
role TEXT NOT NULL DEFAULT 'editor'
|
||||
CHECK (role IN ('owner', 'editor', 'viewer')),
|
||||
created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT,
|
||||
PRIMARY KEY (workspace_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_members_user ON workspace_members(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_invitations (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
||||
email TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'editor'
|
||||
CHECK (role IN ('owner', 'editor', 'viewer')),
|
||||
invited_by TEXT NOT NULL REFERENCES users(id),
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
accepted_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invitations_workspace ON workspace_invitations(workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_invitations_code ON workspace_invitations(code);
|
||||
CREATE INDEX IF NOT EXISTS idx_invitations_email ON workspace_invitations(email);
|
||||
|
||||
-- ========== API Tokens ==========
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT REFERENCES workspaces(id),
|
||||
user_id TEXT REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
prefix TEXT NOT NULL,
|
||||
scopes JSONB NOT NULL DEFAULT '["*"]',
|
||||
expires_at TEXT,
|
||||
last_used_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT
|
||||
);
|
||||
|
||||
-- ========== Webhooks ==========
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
||||
url TEXT NOT NULL,
|
||||
secret TEXT DEFAULT '',
|
||||
events JSONB NOT NULL DEFAULT '["*"]',
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT,
|
||||
last_triggered_at TEXT,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- ========== Agent Roles ==========
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_roles (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT NOT NULL DEFAULT '',
|
||||
tools TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(workspace_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_roles_workspace ON agent_roles(workspace_id);
|
||||
|
||||
-- Foreign keys for items that reference users/agent_roles (added after tables exist)
|
||||
ALTER TABLE items ADD CONSTRAINT fk_items_created_by_user FOREIGN KEY (created_by_user_id) REFERENCES users(id);
|
||||
ALTER TABLE items ADD CONSTRAINT fk_items_modified_by_user FOREIGN KEY (last_modified_by_user_id) REFERENCES users(id);
|
||||
ALTER TABLE items ADD CONSTRAINT fk_items_assigned_user FOREIGN KEY (assigned_user_id) REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE items ADD CONSTRAINT fk_items_agent_role FOREIGN KEY (agent_role_id) REFERENCES agent_roles(id) ON DELETE SET NULL;
|
||||
ALTER TABLE comments ADD CONSTRAINT fk_comments_activity FOREIGN KEY (activity_id) REFERENCES activities(id);
|
||||
|
||||
-- ========== Platform Settings ==========
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT
|
||||
);
|
||||
|
||||
-- ========== Password Resets ==========
|
||||
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
token_hash TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reset_tokens_token_hash ON password_reset_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_reset_tokens_user_id ON password_reset_tokens(user_id);
|
||||
|
||||
-- ========== Legacy tables (kept for compatibility) ==========
|
||||
|
||||
CREATE TABLE IF NOT EXISTS custom_templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
doc_type TEXT NOT NULL DEFAULT 'notes',
|
||||
icon TEXT NOT NULL DEFAULT '📝',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(workspace_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS progress_snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
||||
total_tasks INTEGER NOT NULL DEFAULT 0,
|
||||
done_tasks INTEGER NOT NULL DEFAULT 0,
|
||||
open_tasks INTEGER NOT NULL DEFAULT 0,
|
||||
in_progress INTEGER NOT NULL DEFAULT 0,
|
||||
percentage REAL NOT NULL DEFAULT 0.0,
|
||||
phase_data JSONB NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_workspace_time
|
||||
ON progress_snapshots(workspace_id, created_at);
|
||||
|
||||
-- ========== Migration tracking ==========
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
@@ -5,7 +5,7 @@ import "database/sql"
|
||||
// GetPlatformSetting returns a single platform setting value, or empty string if not set.
|
||||
func (s *Store) GetPlatformSetting(key string) (string, error) {
|
||||
var value string
|
||||
err := s.db.QueryRow("SELECT value FROM platform_settings WHERE key = ?", key).Scan(&value)
|
||||
err := s.db.QueryRow(s.q("SELECT value FROM platform_settings WHERE key = ?"), key).Scan(&value)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
@@ -14,16 +14,16 @@ func (s *Store) GetPlatformSetting(key string) (string, error) {
|
||||
|
||||
// SetPlatformSetting upserts a platform setting.
|
||||
func (s *Store) SetPlatformSetting(key, value string) error {
|
||||
_, err := s.db.Exec(`
|
||||
_, err := s.db.Exec(s.q(`
|
||||
INSERT INTO platform_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
`, key, value, now())
|
||||
`), key, value, now())
|
||||
return err
|
||||
}
|
||||
|
||||
// GetPlatformSettings returns all platform settings as a map.
|
||||
func (s *Store) GetPlatformSettings() (map[string]string, error) {
|
||||
rows, err := s.db.Query("SELECT key, value FROM platform_settings ORDER BY key")
|
||||
rows, err := s.db.Query(s.q("SELECT key, value FROM platform_settings ORDER BY key"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -42,6 +42,6 @@ func (s *Store) GetPlatformSettings() (map[string]string, error) {
|
||||
|
||||
// DeletePlatformSetting removes a platform setting.
|
||||
func (s *Store) DeletePlatformSetting(key string) error {
|
||||
_, err := s.db.Exec("DELETE FROM platform_settings WHERE key = ?", key)
|
||||
_, err := s.db.Exec(s.q("DELETE FROM platform_settings WHERE key = ?"), key)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ func (s *Store) AddReaction(commentID, userID, actor, emoji string) (*models.Rea
|
||||
|
||||
// Store empty string (not NULL) for anonymous users so the UNIQUE constraint
|
||||
// on (comment_id, user_id, emoji) works correctly — SQLite treats NULL != NULL.
|
||||
_, err := s.db.Exec(`
|
||||
_, err := s.db.Exec(s.q(`
|
||||
INSERT INTO comment_reactions (id, comment_id, user_id, actor, emoji, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(comment_id, user_id, emoji) DO NOTHING`,
|
||||
ON CONFLICT(comment_id, user_id, emoji) DO NOTHING`),
|
||||
id, commentID, userID, actor, emoji, ts,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -31,10 +31,10 @@ func (s *Store) AddReaction(commentID, userID, actor, emoji string) (*models.Rea
|
||||
func (s *Store) getReaction(commentID, userID, emoji string) (*models.Reaction, error) {
|
||||
var r models.Reaction
|
||||
var createdAt string
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, comment_id, COALESCE(user_id, ''), actor, emoji, created_at
|
||||
FROM comment_reactions
|
||||
WHERE comment_id = ? AND user_id = ? AND emoji = ?`,
|
||||
WHERE comment_id = ? AND user_id = ? AND emoji = ?`),
|
||||
commentID, userID, emoji,
|
||||
).Scan(&r.ID, &r.CommentID, &r.UserID, &r.Actor, &r.Emoji, &createdAt)
|
||||
if err != nil {
|
||||
@@ -46,9 +46,9 @@ func (s *Store) getReaction(commentID, userID, emoji string) (*models.Reaction,
|
||||
|
||||
// RemoveReaction removes a specific emoji reaction by a user from a comment.
|
||||
func (s *Store) RemoveReaction(commentID, userID, emoji string) error {
|
||||
result, err := s.db.Exec(`
|
||||
result, err := s.db.Exec(s.q(`
|
||||
DELETE FROM comment_reactions
|
||||
WHERE comment_id = ? AND user_id = ? AND emoji = ?`,
|
||||
WHERE comment_id = ? AND user_id = ? AND emoji = ?`),
|
||||
commentID, userID, emoji,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -84,7 +84,7 @@ func (s *Store) ListReactionsByComments(commentIDs []string) (map[string][]model
|
||||
}
|
||||
query += `) ORDER BY cr.created_at ASC`
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
rows, err := s.db.Query(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list reactions: %w", err)
|
||||
}
|
||||
|
||||
+72
-26
@@ -60,13 +60,13 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
refRows, err := s.db.Query(refQuery, refArgs...)
|
||||
refRows, err := s.db.Query(s.q(refQuery), refArgs...)
|
||||
if err == nil {
|
||||
defer refRows.Close()
|
||||
for refRows.Next() {
|
||||
var r SearchResult
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
var pinned bool
|
||||
if err := refRows.Scan(
|
||||
&r.Item.ID, &r.Item.WorkspaceID, &r.Item.CollectionID, &r.Item.Title, &r.Item.Slug,
|
||||
&r.Item.Content, &r.Item.Fields, &r.Item.Tags,
|
||||
@@ -79,7 +79,7 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) {
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
r.Item.Pinned = pinned == 1
|
||||
r.Item.Pinned = pinned
|
||||
r.Item.CreatedAt = parseTime(createdAt)
|
||||
r.Item.UpdatedAt = parseTime(updatedAt)
|
||||
hydrateItemComputedMetadata(&r.Item)
|
||||
@@ -92,25 +92,64 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) {
|
||||
// If no ref matches, fall through to FTS below
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags,
|
||||
i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order,
|
||||
i.created_by, i.last_modified_by, i.source,
|
||||
i.item_number, i.created_at, i.updated_at,
|
||||
c.slug, c.name, c.icon, c.prefix,
|
||||
COALESCE(au.name, ''), COALESCE(au.email, ''),
|
||||
COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, ''),
|
||||
snippet(items_fts, 1, '<mark>', '</mark>', '...', 32) as snippet,
|
||||
rank
|
||||
FROM items_fts fts
|
||||
JOIN items i ON i.rowid = fts.rowid
|
||||
JOIN collections c ON c.id = i.collection_id
|
||||
LEFT JOIN users au ON au.id = i.assigned_user_id
|
||||
LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id
|
||||
WHERE items_fts MATCH ?
|
||||
AND i.deleted_at IS NULL
|
||||
`
|
||||
args := []interface{}{sanitizeFTSQuery(params.Query)}
|
||||
// Build the FTS query — the approach differs between SQLite (FTS5 virtual table)
|
||||
// and PostgreSQL (tsvector column on the items table).
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if s.dialect.Driver() == DriverPostgres {
|
||||
// PostgreSQL: search_vector is a column on the items table (aliased as "i"); no JOIN needed.
|
||||
ftsSnippet := s.dialect.FTSSnippet("i", 1, "i.content")
|
||||
ftsRank := s.dialect.FTSRank("i", "search_vector")
|
||||
ftsMatch := s.dialect.FTSMatch("i", "search_vector")
|
||||
|
||||
// FTSSnippet, FTSRank, and FTSMatch each consume a "?" placeholder
|
||||
// for the query parameter (plainto_tsquery('english', ?)).
|
||||
query = fmt.Sprintf(`
|
||||
SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags,
|
||||
i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order,
|
||||
i.created_by, i.last_modified_by, i.source,
|
||||
i.item_number, i.created_at, i.updated_at,
|
||||
c.slug, c.name, c.icon, c.prefix,
|
||||
COALESCE(au.name, ''), COALESCE(au.email, ''),
|
||||
COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, ''),
|
||||
%s as snippet,
|
||||
%s as rank_score
|
||||
FROM items i
|
||||
JOIN collections c ON c.id = i.collection_id
|
||||
LEFT JOIN users au ON au.id = i.assigned_user_id
|
||||
LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id
|
||||
WHERE %s
|
||||
AND i.deleted_at IS NULL
|
||||
`, ftsSnippet, ftsRank, ftsMatch)
|
||||
searchQuery := params.Query
|
||||
args = []interface{}{searchQuery, searchQuery, searchQuery}
|
||||
} else {
|
||||
// SQLite: uses FTS5 virtual table with JOIN on rowid.
|
||||
ftsSnippet := s.dialect.FTSSnippet("items_fts", 1, "i.content")
|
||||
ftsRank := s.dialect.FTSRank("items_fts", "search_vector")
|
||||
ftsMatch := s.dialect.FTSMatch("items_fts", "search_vector")
|
||||
|
||||
query = fmt.Sprintf(`
|
||||
SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags,
|
||||
i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order,
|
||||
i.created_by, i.last_modified_by, i.source,
|
||||
i.item_number, i.created_at, i.updated_at,
|
||||
c.slug, c.name, c.icon, c.prefix,
|
||||
COALESCE(au.name, ''), COALESCE(au.email, ''),
|
||||
COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, ''),
|
||||
%s as snippet,
|
||||
%s as rank_score
|
||||
FROM items_fts fts
|
||||
JOIN items i ON i.rowid = fts.rowid
|
||||
JOIN collections c ON c.id = i.collection_id
|
||||
LEFT JOIN users au ON au.id = i.assigned_user_id
|
||||
LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id
|
||||
WHERE %s
|
||||
AND i.deleted_at IS NULL
|
||||
`, ftsSnippet, ftsRank, ftsMatch)
|
||||
args = []interface{}{sanitizeFTSQuery(params.Query)}
|
||||
}
|
||||
|
||||
if params.Workspace != "" {
|
||||
query += `
|
||||
@@ -126,9 +165,15 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
query += " ORDER BY rank LIMIT 50"
|
||||
// SQLite bm25() returns negative values (more negative = more relevant) → ASC.
|
||||
// PostgreSQL ts_rank() returns positive values (higher = more relevant) → DESC.
|
||||
if s.dialect.Driver() == DriverPostgres {
|
||||
query += " ORDER BY rank_score DESC LIMIT 50"
|
||||
} else {
|
||||
query += " ORDER BY rank_score LIMIT 50"
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
rows, err := s.db.Query(s.q(query), args...)
|
||||
if err != nil {
|
||||
// If we already have a ref match, return that instead of failing
|
||||
// (FTS5 may reject queries like "TASK-5" due to special syntax)
|
||||
@@ -148,7 +193,7 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) {
|
||||
for rows.Next() {
|
||||
var r SearchResult
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
var pinned bool
|
||||
|
||||
if err := rows.Scan(
|
||||
&r.Item.ID, &r.Item.WorkspaceID, &r.Item.CollectionID, &r.Item.Title, &r.Item.Slug,
|
||||
@@ -167,7 +212,7 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) {
|
||||
if seen[r.Item.ID] {
|
||||
continue
|
||||
}
|
||||
r.Item.Pinned = pinned == 1
|
||||
r.Item.Pinned = pinned
|
||||
r.Item.CreatedAt = parseTime(createdAt)
|
||||
r.Item.UpdatedAt = parseTime(updatedAt)
|
||||
hydrateItemComputedMetadata(&r.Item)
|
||||
@@ -180,6 +225,7 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) {
|
||||
|
||||
// sanitizeFTSQuery wraps each token in double quotes so FTS5 treats
|
||||
// special characters (like hyphens) as literals rather than operators.
|
||||
// Only used for SQLite FTS5 queries.
|
||||
func sanitizeFTSQuery(q string) string {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
|
||||
@@ -29,10 +29,10 @@ func (s *Store) CreateSession(userID, deviceInfo string, ttl time.Duration) (str
|
||||
ts := now()
|
||||
expiresAt := time.Now().UTC().Add(ttl).Format(time.RFC3339)
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
_, err := s.db.Exec(s.q(`
|
||||
INSERT INTO sessions (id, user_id, token_hash, device_info, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, id, userID, tokenHash, deviceInfo, expiresAt, ts)
|
||||
`), id, userID, tokenHash, deviceInfo, expiresAt, ts)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("insert session: %w", err)
|
||||
}
|
||||
@@ -47,9 +47,9 @@ func (s *Store) ValidateSession(token string) (*models.User, error) {
|
||||
tokenHash := hex.EncodeToString(hash[:])
|
||||
|
||||
var userID, expiresAt string
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT user_id, expires_at FROM sessions WHERE token_hash = ?
|
||||
`, tokenHash).Scan(&userID, &expiresAt)
|
||||
`), tokenHash).Scan(&userID, &expiresAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func (s *Store) DeleteSession(token string) error {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
tokenHash := hex.EncodeToString(hash[:])
|
||||
|
||||
_, err := s.db.Exec("DELETE FROM sessions WHERE token_hash = ?", tokenHash)
|
||||
_, err := s.db.Exec(s.q("DELETE FROM sessions WHERE token_hash = ?"), tokenHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete session: %w", err)
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func (s *Store) DeleteSession(token string) error {
|
||||
|
||||
// DeleteUserSessions destroys all sessions for a user (logout everywhere).
|
||||
func (s *Store) DeleteUserSessions(userID string) error {
|
||||
_, err := s.db.Exec("DELETE FROM sessions WHERE user_id = ?", userID)
|
||||
_, err := s.db.Exec(s.q("DELETE FROM sessions WHERE user_id = ?"), userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete user sessions: %w", err)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func (s *Store) DeleteUserSessions(userID string) error {
|
||||
|
||||
// CleanExpiredSessions removes all sessions past their expiry time.
|
||||
func (s *Store) CleanExpiredSessions() error {
|
||||
_, err := s.db.Exec("DELETE FROM sessions WHERE expires_at < ?", now())
|
||||
_, err := s.db.Exec(s.q("DELETE FROM sessions WHERE expires_at < ?"), now())
|
||||
if err != nil {
|
||||
return fmt.Errorf("clean expired sessions: %w", err)
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
|
||||
// CreateSnapshot inserts a new progress snapshot.
|
||||
func (s *Store) CreateSnapshot(snap models.ProgressSnapshot) error {
|
||||
_, err := s.db.Exec(`
|
||||
_, err := s.db.Exec(s.q(`
|
||||
INSERT INTO progress_snapshots (id, workspace_id, total_tasks, done_tasks, open_tasks, in_progress, percentage, phase_data, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
||||
newID(), snap.WorkspaceID, snap.TotalTasks, snap.DoneTasks, snap.OpenTasks, snap.InProgress, snap.Percentage, snap.PhaseData, now(),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -44,7 +44,7 @@ func (s *Store) ListSnapshots(workspaceID string, params models.SnapshotListPara
|
||||
query += fmt.Sprintf(" LIMIT %d", params.Limit)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
rows, err := s.db.Query(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list snapshots: %w", err)
|
||||
}
|
||||
@@ -67,12 +67,12 @@ func (s *Store) ListSnapshots(workspaceID string, params models.SnapshotListPara
|
||||
func (s *Store) LatestSnapshot(workspaceID string) (*models.ProgressSnapshot, error) {
|
||||
var snap models.ProgressSnapshot
|
||||
var createdAt string
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, workspace_id, total_tasks, done_tasks, open_tasks, in_progress, percentage, phase_data, created_at
|
||||
FROM progress_snapshots
|
||||
WHERE workspace_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
LIMIT 1`),
|
||||
workspaceID,
|
||||
).Scan(&snap.ID, &snap.WorkspaceID, &snap.TotalTasks, &snap.DoneTasks, &snap.OpenTasks, &snap.InProgress, &snap.Percentage, &snap.PhaseData, &createdAt)
|
||||
|
||||
@@ -88,9 +88,9 @@ func (s *Store) LatestSnapshot(workspaceID string) (*models.ProgressSnapshot, er
|
||||
|
||||
// DeleteOldSnapshots removes snapshots older than the given time.
|
||||
func (s *Store) DeleteOldSnapshots(workspaceID string, olderThan time.Time) (int64, error) {
|
||||
result, err := s.db.Exec(`
|
||||
result, err := s.db.Exec(s.q(`
|
||||
DELETE FROM progress_snapshots
|
||||
WHERE workspace_id = ? AND created_at < ?`,
|
||||
WHERE workspace_id = ? AND created_at < ?`),
|
||||
workspaceID, olderThan.UTC().Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
+124
-10
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
|
||||
"github.com/xarmian/pad/internal/collections"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
@@ -15,10 +16,21 @@ import (
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
//go:embed pgmigrations/*.sql
|
||||
var pgMigrationsFS embed.FS
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
db *sql.DB
|
||||
dialect Dialect
|
||||
}
|
||||
|
||||
// D returns the store's dialect for building backend-specific SQL.
|
||||
func (s *Store) D() Dialect { return s.dialect }
|
||||
|
||||
// DB returns the underlying *sql.DB (for use in migrations/testing).
|
||||
func (s *Store) DB() *sql.DB { return s.db }
|
||||
|
||||
// New creates a Store backed by SQLite at the given path.
|
||||
func New(dbPath string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", dbPath+"?_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
@@ -35,7 +47,7 @@ func New(dbPath string) (*Store, error) {
|
||||
return nil, fmt.Errorf("enable foreign keys: %w", err)
|
||||
}
|
||||
|
||||
s := &Store{db: db}
|
||||
s := &Store{db: db, dialect: &sqliteDialect{}}
|
||||
if err := s.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
@@ -51,10 +63,49 @@ func New(dbPath string) (*Store, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// NewPostgres creates a Store backed by PostgreSQL.
|
||||
// The connStr should be a PostgreSQL connection string (e.g. "postgres://user:pass@host/db").
|
||||
func NewPostgres(connStr string) (*Store, error) {
|
||||
db, err := sql.Open("pgx", connStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open postgres: %w", err)
|
||||
}
|
||||
|
||||
// Verify connection
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
|
||||
// Connection pool tuning for cloud deployment
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
s := &Store{db: db, dialect: &postgresDialect{}}
|
||||
if err := s.migratePostgres(); err != nil {
|
||||
return nil, fmt.Errorf("migrate postgres: %w", err)
|
||||
}
|
||||
|
||||
if err := s.backfillItemNumbers(); err != nil {
|
||||
return nil, fmt.Errorf("backfill item numbers: %w", err)
|
||||
}
|
||||
|
||||
if err := s.backfillWorkspaceOwners(); err != nil {
|
||||
return nil, fmt.Errorf("backfill workspace owners: %w", err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// Ping verifies the database connection is alive.
|
||||
func (s *Store) Ping() error {
|
||||
return s.db.Ping()
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
// Create migrations tracking table
|
||||
_, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
@@ -118,6 +169,49 @@ func (s *Store) migrate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migratePostgres applies PostgreSQL migrations.
|
||||
// PostgreSQL supports multi-statement execution natively, so we don't need execMulti.
|
||||
func (s *Store) migratePostgres() error {
|
||||
// Create migrations tracking table
|
||||
_, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create migrations table: %w", err)
|
||||
}
|
||||
|
||||
migrations := []string{
|
||||
"001_initial.sql",
|
||||
}
|
||||
|
||||
for _, name := range migrations {
|
||||
var count int
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = $1", name).Scan(&count); err != nil {
|
||||
return fmt.Errorf("check migration %s: %w", name, err)
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := pgMigrationsFS.ReadFile("pgmigrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
if _, err := s.db.Exec(string(data)); err != nil {
|
||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec("INSERT INTO schema_migrations (version, applied_at) VALUES ($1, $2)", name, now())
|
||||
if err != nil {
|
||||
return fmt.Errorf("record migration %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// execMulti executes multiple SQL statements by iteratively using
|
||||
// database/sql's Exec which processes one statement at a time,
|
||||
// then advancing past it using the driver's awareness of statement boundaries.
|
||||
@@ -223,7 +317,7 @@ func (s *Store) uniqueSlugExcluding(table, scopeCol, scopeVal, baseSlug, exclude
|
||||
for i := 2; ; i++ {
|
||||
var count int
|
||||
query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s = ? AND slug = ? AND id != ?", table, scopeCol)
|
||||
err := s.db.QueryRow(query, scopeVal, slug, excludeID).Scan(&count)
|
||||
err := s.db.QueryRow(s.q(query), scopeVal, slug, excludeID).Scan(&count)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -238,6 +332,26 @@ func isAlpha(b byte) bool {
|
||||
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_'
|
||||
}
|
||||
|
||||
// isValidFieldKey checks that a field name contains only safe characters
|
||||
// (alphanumeric, underscore, hyphen). This prevents SQL injection when
|
||||
// field keys from user input are interpolated into JSON path expressions.
|
||||
func isValidFieldKey(key string) bool {
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
for _, c := range key {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// q rebinds a query to the store's dialect (converts "?" to "$1", "$2", etc. for PostgreSQL).
|
||||
func (s *Store) q(query string) string {
|
||||
return s.dialect.Rebind(query)
|
||||
}
|
||||
|
||||
func newID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
@@ -285,7 +399,7 @@ func slugify(s string) string {
|
||||
// ensures the unique index on (collection_id, item_number) exists.
|
||||
func (s *Store) backfillItemNumbers() error {
|
||||
// 1. Backfill collection prefixes
|
||||
rows, err := s.db.Query("SELECT id, name FROM collections WHERE prefix = ''")
|
||||
rows, err := s.db.Query(s.q("SELECT id, name FROM collections WHERE prefix = ''"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("query collections for prefix backfill: %w", err)
|
||||
}
|
||||
@@ -311,13 +425,13 @@ func (s *Store) backfillItemNumbers() error {
|
||||
if prefix == "" {
|
||||
prefix = "ITEM"
|
||||
}
|
||||
if _, err := s.db.Exec("UPDATE collections SET prefix = ? WHERE id = ?", prefix, c.id); err != nil {
|
||||
if _, err := s.db.Exec(s.q("UPDATE collections SET prefix = ? WHERE id = ?"), prefix, c.id); err != nil {
|
||||
return fmt.Errorf("update prefix for collection %s: %w", c.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Backfill item numbers per collection
|
||||
collRows, err := s.db.Query("SELECT id FROM collections")
|
||||
collRows, err := s.db.Query(s.q("SELECT id FROM collections"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("query collections for item number backfill: %w", err)
|
||||
}
|
||||
@@ -337,7 +451,7 @@ func (s *Store) backfillItemNumbers() error {
|
||||
|
||||
for _, collID := range collIDs {
|
||||
itemRows, err := s.db.Query(
|
||||
"SELECT id FROM items WHERE collection_id = ? AND item_number IS NULL ORDER BY created_at ASC, id ASC",
|
||||
s.q("SELECT id FROM items WHERE collection_id = ? AND item_number IS NULL ORDER BY created_at ASC, id ASC"),
|
||||
collID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -360,13 +474,13 @@ func (s *Store) backfillItemNumbers() error {
|
||||
|
||||
// Get current max
|
||||
var maxNum int
|
||||
if err := s.db.QueryRow("SELECT COALESCE(MAX(item_number), 0) FROM items WHERE collection_id = ?", collID).Scan(&maxNum); err != nil {
|
||||
if err := s.db.QueryRow(s.q("SELECT COALESCE(MAX(item_number), 0) FROM items WHERE collection_id = ?"), collID).Scan(&maxNum); err != nil {
|
||||
return fmt.Errorf("get max item_number for collection %s: %w", collID, err)
|
||||
}
|
||||
|
||||
for _, itemID := range itemIDs {
|
||||
maxNum++
|
||||
if _, err := s.db.Exec("UPDATE items SET item_number = ? WHERE id = ?", maxNum, itemID); err != nil {
|
||||
if _, err := s.db.Exec(s.q("UPDATE items SET item_number = ? WHERE id = ?"), maxNum, itemID); err != nil {
|
||||
return fmt.Errorf("update item_number for item %s: %w", itemID, err)
|
||||
}
|
||||
}
|
||||
@@ -388,7 +502,7 @@ func (s *Store) uniqueSlug(table, scopeCol, scopeVal, baseSlug string) (string,
|
||||
var count int
|
||||
// Check all rows including soft-deleted to respect the DB UNIQUE constraint
|
||||
query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s = ? AND slug = ?", table, scopeCol)
|
||||
err := s.db.QueryRow(query, scopeVal, slug).Scan(&count)
|
||||
err := s.db.QueryRow(s.q(query), scopeVal, slug).Scan(&count)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
)
|
||||
|
||||
func (s *Store) ListCustomTemplates(workspaceID string) ([]models.CustomTemplate, error) {
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT id, workspace_id, name, description, doc_type, icon, content, created_at, updated_at
|
||||
FROM custom_templates
|
||||
WHERE workspace_id = ?
|
||||
ORDER BY name ASC
|
||||
`, workspaceID)
|
||||
`), workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list custom templates: %w", err)
|
||||
}
|
||||
@@ -36,11 +36,11 @@ func (s *Store) ListCustomTemplates(workspaceID string) ([]models.CustomTemplate
|
||||
func (s *Store) GetCustomTemplate(id string) (*models.CustomTemplate, error) {
|
||||
var t models.CustomTemplate
|
||||
var createdAt, updatedAt string
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, workspace_id, name, description, doc_type, icon, content, created_at, updated_at
|
||||
FROM custom_templates
|
||||
WHERE id = ?
|
||||
`, id).Scan(&t.ID, &t.WorkspaceID, &t.Name, &t.Description, &t.DocType, &t.Icon, &t.Content, &createdAt, &updatedAt)
|
||||
`), id).Scan(&t.ID, &t.WorkspaceID, &t.Name, &t.Description, &t.DocType, &t.Icon, &t.Content, &createdAt, &updatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -56,10 +56,10 @@ func (s *Store) CreateCustomTemplate(input models.CustomTemplateCreate) (*models
|
||||
id := newID()
|
||||
ts := now()
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
_, err := s.db.Exec(s.q(`
|
||||
INSERT INTO custom_templates (id, workspace_id, name, description, doc_type, icon, content, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, id, input.WorkspaceID, input.Name, input.Description, input.DocType, input.Icon, input.Content, ts, ts)
|
||||
`), id, input.WorkspaceID, input.Name, input.Description, input.DocType, input.Icon, input.Content, ts, ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create custom template: %w", err)
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func (s *Store) CreateCustomTemplate(input models.CustomTemplateCreate) (*models
|
||||
}
|
||||
|
||||
func (s *Store) DeleteCustomTemplate(id string) error {
|
||||
result, err := s.db.Exec(`DELETE FROM custom_templates WHERE id = ?`, id)
|
||||
result, err := s.db.Exec(s.q(`DELETE FROM custom_templates WHERE id = ?`), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+10
-10
@@ -26,10 +26,10 @@ func (s *Store) CreateUser(input models.UserCreate) (*models.User, error) {
|
||||
id := newID()
|
||||
ts := now()
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
_, err = s.db.Exec(s.q(`
|
||||
INSERT INTO users (id, email, name, password_hash, role, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, id, strings.ToLower(strings.TrimSpace(input.Email)), strings.TrimSpace(input.Name), string(hash), role, ts, ts)
|
||||
`), id, strings.ToLower(strings.TrimSpace(input.Email)), strings.TrimSpace(input.Name), string(hash), role, ts, ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert user: %w", err)
|
||||
}
|
||||
@@ -42,10 +42,10 @@ func (s *Store) GetUser(id string) (*models.User, error) {
|
||||
var u models.User
|
||||
var createdAt, updatedAt string
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, email, name, password_hash, role, avatar_url, created_at, updated_at
|
||||
FROM users WHERE id = ?
|
||||
`, id).Scan(
|
||||
`), id).Scan(
|
||||
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &u.Role, &u.AvatarURL,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
@@ -66,10 +66,10 @@ func (s *Store) GetUserByEmail(email string) (*models.User, error) {
|
||||
var u models.User
|
||||
var createdAt, updatedAt string
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, email, name, password_hash, role, avatar_url, created_at, updated_at
|
||||
FROM users WHERE email = ?
|
||||
`, strings.ToLower(strings.TrimSpace(email))).Scan(
|
||||
`), strings.ToLower(strings.TrimSpace(email))).Scan(
|
||||
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &u.Role, &u.AvatarURL,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
@@ -116,7 +116,7 @@ func (s *Store) UpdateUser(id string, input models.UserUpdate) (*models.User, er
|
||||
args = append(args, id)
|
||||
|
||||
query := fmt.Sprintf("UPDATE users SET %s WHERE id = ?", strings.Join(sets, ", "))
|
||||
result, err := s.db.Exec(query, args...)
|
||||
result, err := s.db.Exec(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update user: %w", err)
|
||||
}
|
||||
@@ -148,10 +148,10 @@ func (s *Store) ValidatePassword(email, password string) (*models.User, error) {
|
||||
|
||||
// ListUsers returns all users.
|
||||
func (s *Store) ListUsers() ([]models.User, error) {
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT id, email, name, password_hash, role, avatar_url, created_at, updated_at
|
||||
FROM users ORDER BY created_at ASC
|
||||
`)
|
||||
`))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list users: %w", err)
|
||||
}
|
||||
@@ -177,7 +177,7 @@ func (s *Store) ListUsers() ([]models.User, error) {
|
||||
// UserCount returns the total number of registered users.
|
||||
func (s *Store) UserCount() (int, error) {
|
||||
var count int
|
||||
err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||
err := s.db.QueryRow(s.q("SELECT COUNT(*) FROM users")).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count users: %w", err)
|
||||
}
|
||||
|
||||
+12
-12
@@ -51,32 +51,32 @@ func (s *Store) GetLatestVersion(documentID string) (*models.Version, error) {
|
||||
func (s *Store) getLatestVersionRaw(documentID string) (*models.Version, error) {
|
||||
var v models.Version
|
||||
var createdAt string
|
||||
var isDiff int
|
||||
err := s.db.QueryRow(`
|
||||
var isDiff bool
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, document_id, content, change_summary, created_by, source, is_diff, created_at
|
||||
FROM versions
|
||||
WHERE document_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, documentID).Scan(&v.ID, &v.DocumentID, &v.Content, &v.ChangeSummary, &v.CreatedBy, &v.Source, &isDiff, &createdAt)
|
||||
`), documentID).Scan(&v.ID, &v.DocumentID, &v.Content, &v.ChangeSummary, &v.CreatedBy, &v.Source, &isDiff, &createdAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.IsDiff = isDiff == 1
|
||||
v.IsDiff = isDiff
|
||||
v.CreatedAt = parseTime(createdAt)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListVersions(documentID string) ([]models.Version, error) {
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT id, document_id, content, change_summary, created_by, source, is_diff, created_at
|
||||
FROM versions
|
||||
WHERE document_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`, documentID)
|
||||
`), documentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -86,11 +86,11 @@ func (s *Store) ListVersions(documentID string) ([]models.Version, error) {
|
||||
for rows.Next() {
|
||||
var v models.Version
|
||||
var createdAt string
|
||||
var isDiff int
|
||||
var isDiff bool
|
||||
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Content, &v.ChangeSummary, &v.CreatedBy, &v.Source, &isDiff, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.IsDiff = isDiff == 1
|
||||
v.IsDiff = isDiff
|
||||
v.CreatedAt = parseTime(createdAt)
|
||||
versions = append(versions, v)
|
||||
}
|
||||
@@ -133,19 +133,19 @@ func (s *Store) ListVersionsResolved(documentID, currentContent string) ([]model
|
||||
func (s *Store) GetVersion(id string) (*models.Version, error) {
|
||||
var v models.Version
|
||||
var createdAt string
|
||||
var isDiff int
|
||||
err := s.db.QueryRow(`
|
||||
var isDiff bool
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, document_id, content, change_summary, created_by, source, is_diff, created_at
|
||||
FROM versions
|
||||
WHERE id = ?
|
||||
`, id).Scan(&v.ID, &v.DocumentID, &v.Content, &v.ChangeSummary, &v.CreatedBy, &v.Source, &isDiff, &createdAt)
|
||||
`), id).Scan(&v.ID, &v.DocumentID, &v.Content, &v.ChangeSummary, &v.CreatedBy, &v.Source, &isDiff, &createdAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.IsDiff = isDiff == 1
|
||||
v.IsDiff = isDiff
|
||||
v.CreatedAt = parseTime(createdAt)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
+17
-17
@@ -31,10 +31,10 @@ func (s *Store) CreateView(workspaceID string, input models.ViewCreate) (*models
|
||||
viewType = "list"
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
_, err = s.db.Exec(s.q(`
|
||||
INSERT INTO views (id, workspace_id, collection_id, name, slug, view_type, config, sort_order, is_default, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`,
|
||||
id, workspaceID, input.CollectionID, input.Name, slug, viewType, config, ts, ts,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`),
|
||||
id, workspaceID, input.CollectionID, input.Name, slug, viewType, config, s.dialect.BoolToInt(false), ts, ts,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert view: %w", err)
|
||||
@@ -47,13 +47,13 @@ func (s *Store) CreateView(workspaceID string, input models.ViewCreate) (*models
|
||||
func (s *Store) GetView(id string) (*models.View, error) {
|
||||
var v models.View
|
||||
var collectionID *string
|
||||
var isDefault int
|
||||
var isDefault bool
|
||||
var createdAt, updatedAt string
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, workspace_id, collection_id, name, slug, view_type, config, sort_order, is_default, created_at, updated_at
|
||||
FROM views
|
||||
WHERE id = ?`, id).Scan(
|
||||
WHERE id = ?`), id).Scan(
|
||||
&v.ID, &v.WorkspaceID, &collectionID, &v.Name, &v.Slug, &v.ViewType,
|
||||
&v.Config, &v.SortOrder, &isDefault, &createdAt, &updatedAt,
|
||||
)
|
||||
@@ -65,7 +65,7 @@ func (s *Store) GetView(id string) (*models.View, error) {
|
||||
}
|
||||
|
||||
v.CollectionID = collectionID
|
||||
v.IsDefault = isDefault == 1
|
||||
v.IsDefault = isDefault
|
||||
v.CreatedAt = parseTime(createdAt)
|
||||
v.UpdatedAt = parseTime(updatedAt)
|
||||
return &v, nil
|
||||
@@ -75,13 +75,13 @@ func (s *Store) GetView(id string) (*models.View, error) {
|
||||
func (s *Store) GetViewBySlug(workspaceID, slug string) (*models.View, error) {
|
||||
var v models.View
|
||||
var collectionID *string
|
||||
var isDefault int
|
||||
var isDefault bool
|
||||
var createdAt, updatedAt string
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, workspace_id, collection_id, name, slug, view_type, config, sort_order, is_default, created_at, updated_at
|
||||
FROM views
|
||||
WHERE workspace_id = ? AND slug = ?`, workspaceID, slug).Scan(
|
||||
WHERE workspace_id = ? AND slug = ?`), workspaceID, slug).Scan(
|
||||
&v.ID, &v.WorkspaceID, &collectionID, &v.Name, &v.Slug, &v.ViewType,
|
||||
&v.Config, &v.SortOrder, &isDefault, &createdAt, &updatedAt,
|
||||
)
|
||||
@@ -93,7 +93,7 @@ func (s *Store) GetViewBySlug(workspaceID, slug string) (*models.View, error) {
|
||||
}
|
||||
|
||||
v.CollectionID = collectionID
|
||||
v.IsDefault = isDefault == 1
|
||||
v.IsDefault = isDefault
|
||||
v.CreatedAt = parseTime(createdAt)
|
||||
v.UpdatedAt = parseTime(updatedAt)
|
||||
return &v, nil
|
||||
@@ -101,11 +101,11 @@ func (s *Store) GetViewBySlug(workspaceID, slug string) (*models.View, error) {
|
||||
|
||||
// ListViews returns all views for a collection within a workspace.
|
||||
func (s *Store) ListViews(workspaceID, collectionID string) ([]models.View, error) {
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT id, workspace_id, collection_id, name, slug, view_type, config, sort_order, is_default, created_at, updated_at
|
||||
FROM views
|
||||
WHERE workspace_id = ? AND collection_id = ?
|
||||
ORDER BY sort_order ASC, created_at ASC`, workspaceID, collectionID)
|
||||
ORDER BY sort_order ASC, created_at ASC`), workspaceID, collectionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list views: %w", err)
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func (s *Store) ListViews(workspaceID, collectionID string) ([]models.View, erro
|
||||
for rows.Next() {
|
||||
var v models.View
|
||||
var collID *string
|
||||
var isDefault int
|
||||
var isDefault bool
|
||||
var createdAt, updatedAt string
|
||||
|
||||
if err := rows.Scan(
|
||||
@@ -125,7 +125,7 @@ func (s *Store) ListViews(workspaceID, collectionID string) ([]models.View, erro
|
||||
return nil, fmt.Errorf("scan view: %w", err)
|
||||
}
|
||||
v.CollectionID = collID
|
||||
v.IsDefault = isDefault == 1
|
||||
v.IsDefault = isDefault
|
||||
v.CreatedAt = parseTime(createdAt)
|
||||
v.UpdatedAt = parseTime(updatedAt)
|
||||
views = append(views, v)
|
||||
@@ -169,7 +169,7 @@ func (s *Store) UpdateView(id string, input models.ViewUpdate) (*models.View, er
|
||||
}
|
||||
query += " WHERE id = ?"
|
||||
|
||||
result, err := s.db.Exec(query, args...)
|
||||
result, err := s.db.Exec(s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update view: %w", err)
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func (s *Store) UpdateView(id string, input models.ViewUpdate) (*models.View, er
|
||||
|
||||
// DeleteView removes a view by ID.
|
||||
func (s *Store) DeleteView(id string) error {
|
||||
result, err := s.db.Exec("DELETE FROM views WHERE id = ?", id)
|
||||
result, err := s.db.Exec(s.q("DELETE FROM views WHERE id = ?"), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete view: %w", err)
|
||||
}
|
||||
|
||||
+17
-17
@@ -17,10 +17,10 @@ func (s *Store) CreateWebhook(workspaceID string, input models.WebhookCreate) (*
|
||||
evts = `["*"]`
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
_, err := s.db.Exec(s.q(`
|
||||
INSERT INTO webhooks (id, workspace_id, url, secret, events, active, created_at, updated_at, failure_count)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?, 0)
|
||||
`, id, workspaceID, input.URL, input.Secret, evts, ts, ts)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
`), id, workspaceID, input.URL, input.Secret, evts, s.dialect.BoolToInt(true), ts, ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert webhook: %w", err)
|
||||
}
|
||||
@@ -31,15 +31,15 @@ func (s *Store) CreateWebhook(workspaceID string, input models.WebhookCreate) (*
|
||||
// GetWebhook retrieves a single webhook by ID.
|
||||
func (s *Store) GetWebhook(id string) (*models.Webhook, error) {
|
||||
var wh models.Webhook
|
||||
var active int
|
||||
var active bool
|
||||
var createdAt, updatedAt string
|
||||
var lastTriggeredAt *string
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, workspace_id, url, secret, events, active, created_at, updated_at, last_triggered_at, failure_count
|
||||
FROM webhooks
|
||||
WHERE id = ?
|
||||
`, id).Scan(
|
||||
`), id).Scan(
|
||||
&wh.ID, &wh.WorkspaceID, &wh.URL, &wh.Secret, &wh.Events,
|
||||
&active, &createdAt, &updatedAt, &lastTriggeredAt, &wh.FailureCount,
|
||||
)
|
||||
@@ -50,7 +50,7 @@ func (s *Store) GetWebhook(id string) (*models.Webhook, error) {
|
||||
return nil, fmt.Errorf("get webhook: %w", err)
|
||||
}
|
||||
|
||||
wh.Active = active == 1
|
||||
wh.Active = active
|
||||
wh.CreatedAt = parseTime(createdAt)
|
||||
wh.UpdatedAt = parseTime(updatedAt)
|
||||
wh.LastTriggeredAt = parseTimePtr(lastTriggeredAt)
|
||||
@@ -59,12 +59,12 @@ func (s *Store) GetWebhook(id string) (*models.Webhook, error) {
|
||||
|
||||
// ListWebhooks returns all webhooks for a workspace.
|
||||
func (s *Store) ListWebhooks(workspaceID string) ([]models.Webhook, error) {
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT id, workspace_id, url, secret, events, active, created_at, updated_at, last_triggered_at, failure_count
|
||||
FROM webhooks
|
||||
WHERE workspace_id = ?
|
||||
ORDER BY created_at ASC
|
||||
`, workspaceID)
|
||||
`), workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list webhooks: %w", err)
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func (s *Store) ListWebhooks(workspaceID string) ([]models.Webhook, error) {
|
||||
var result []models.Webhook
|
||||
for rows.Next() {
|
||||
var wh models.Webhook
|
||||
var active int
|
||||
var active bool
|
||||
var createdAt, updatedAt string
|
||||
var lastTriggeredAt *string
|
||||
|
||||
@@ -83,7 +83,7 @@ func (s *Store) ListWebhooks(workspaceID string) ([]models.Webhook, error) {
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan webhook: %w", err)
|
||||
}
|
||||
wh.Active = active == 1
|
||||
wh.Active = active
|
||||
wh.CreatedAt = parseTime(createdAt)
|
||||
wh.UpdatedAt = parseTime(updatedAt)
|
||||
wh.LastTriggeredAt = parseTimePtr(lastTriggeredAt)
|
||||
@@ -94,7 +94,7 @@ func (s *Store) ListWebhooks(workspaceID string) ([]models.Webhook, error) {
|
||||
|
||||
// DeleteWebhook removes a webhook by ID.
|
||||
func (s *Store) DeleteWebhook(id string) error {
|
||||
result, err := s.db.Exec("DELETE FROM webhooks WHERE id = ?", id)
|
||||
result, err := s.db.Exec(s.q("DELETE FROM webhooks WHERE id = ?"), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete webhook: %w", err)
|
||||
}
|
||||
@@ -112,24 +112,24 @@ func (s *Store) DeleteWebhook(id string) error {
|
||||
func (s *Store) UpdateWebhookFailure(id string, failed bool) error {
|
||||
ts := now()
|
||||
if failed {
|
||||
_, err := s.db.Exec(`
|
||||
_, err := s.db.Exec(s.q(`
|
||||
UPDATE webhooks
|
||||
SET failure_count = failure_count + 1,
|
||||
updated_at = ?,
|
||||
active = CASE WHEN failure_count + 1 >= 10 THEN 0 ELSE active END
|
||||
active = CASE WHEN failure_count + 1 >= 10 THEN FALSE ELSE active END
|
||||
WHERE id = ?
|
||||
`, ts, id)
|
||||
`), ts, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update webhook failure: %w", err)
|
||||
}
|
||||
} else {
|
||||
_, err := s.db.Exec(`
|
||||
_, err := s.db.Exec(s.q(`
|
||||
UPDATE webhooks
|
||||
SET failure_count = 0,
|
||||
last_triggered_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`, ts, ts, id)
|
||||
`), ts, ts, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update webhook success: %w", err)
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
// AddWorkspaceMember adds a user to a workspace with the given role.
|
||||
func (s *Store) AddWorkspaceMember(workspaceID, userID, role string) error {
|
||||
ts := now()
|
||||
_, err := s.db.Exec(`
|
||||
_, err := s.db.Exec(s.q(`
|
||||
INSERT INTO workspace_members (workspace_id, user_id, role, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, workspaceID, userID, role, ts)
|
||||
`), workspaceID, userID, role, ts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add workspace member: %w", err)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func (s *Store) AddWorkspaceMember(workspaceID, userID, role string) error {
|
||||
// RemoveWorkspaceMember removes a user from a workspace.
|
||||
func (s *Store) RemoveWorkspaceMember(workspaceID, userID string) error {
|
||||
result, err := s.db.Exec(
|
||||
"DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?",
|
||||
s.q("DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?"),
|
||||
workspaceID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -44,11 +44,11 @@ func (s *Store) GetWorkspaceMember(workspaceID, userID string) (*models.Workspac
|
||||
var m models.WorkspaceMember
|
||||
var createdAt string
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT workspace_id, user_id, role, created_at
|
||||
FROM workspace_members
|
||||
WHERE workspace_id = ? AND user_id = ?
|
||||
`, workspaceID, userID).Scan(
|
||||
`), workspaceID, userID).Scan(
|
||||
&m.WorkspaceID, &m.UserID, &m.Role, &createdAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -65,14 +65,14 @@ func (s *Store) GetWorkspaceMember(workspaceID, userID string) (*models.Workspac
|
||||
// ListWorkspaceMembers returns all members of a workspace, enriched with
|
||||
// user name and email from a join.
|
||||
func (s *Store) ListWorkspaceMembers(workspaceID string) ([]models.WorkspaceMember, error) {
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT wm.workspace_id, wm.user_id, wm.role, wm.created_at,
|
||||
u.name, u.email
|
||||
FROM workspace_members wm
|
||||
JOIN users u ON u.id = wm.user_id
|
||||
WHERE wm.workspace_id = ?
|
||||
ORDER BY wm.created_at ASC
|
||||
`, workspaceID)
|
||||
`), workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list workspace members: %w", err)
|
||||
}
|
||||
@@ -96,13 +96,13 @@ func (s *Store) ListWorkspaceMembers(workspaceID string) ([]models.WorkspaceMemb
|
||||
|
||||
// GetUserWorkspaces returns all workspaces a user has access to.
|
||||
func (s *Store) GetUserWorkspaces(userID string) ([]models.Workspace, error) {
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at, w.deleted_at
|
||||
FROM workspaces w
|
||||
JOIN workspace_members wm ON wm.workspace_id = w.id
|
||||
WHERE wm.user_id = ? AND w.deleted_at IS NULL
|
||||
ORDER BY w.name ASC
|
||||
`, userID)
|
||||
`), userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user workspaces: %w", err)
|
||||
}
|
||||
@@ -131,7 +131,7 @@ func (s *Store) GetUserWorkspaces(userID string) ([]models.Workspace, error) {
|
||||
func (s *Store) IsWorkspaceMember(workspaceID, userID string) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRow(
|
||||
"SELECT COUNT(*) FROM workspace_members WHERE workspace_id = ? AND user_id = ?",
|
||||
s.q("SELECT COUNT(*) FROM workspace_members WHERE workspace_id = ? AND user_id = ?"),
|
||||
workspaceID, userID,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
@@ -143,7 +143,7 @@ func (s *Store) IsWorkspaceMember(workspaceID, userID string) (bool, error) {
|
||||
// UpdateWorkspaceMemberRole changes a member's role in a workspace.
|
||||
func (s *Store) UpdateWorkspaceMemberRole(workspaceID, userID, role string) error {
|
||||
result, err := s.db.Exec(
|
||||
"UPDATE workspace_members SET role = ? WHERE workspace_id = ? AND user_id = ?",
|
||||
s.q("UPDATE workspace_members SET role = ? WHERE workspace_id = ? AND user_id = ?"),
|
||||
role, workspaceID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -170,10 +170,10 @@ func (s *Store) CreateInvitation(workspaceID, email, role, invitedBy string) (*m
|
||||
id := newID()
|
||||
ts := now()
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
_, err := s.db.Exec(s.q(`
|
||||
INSERT INTO workspace_invitations (id, workspace_id, email, role, invited_by, code, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, id, workspaceID, strings.ToLower(strings.TrimSpace(email)), role, invitedBy, code, ts)
|
||||
`), id, workspaceID, strings.ToLower(strings.TrimSpace(email)), role, invitedBy, code, ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert invitation: %w", err)
|
||||
}
|
||||
@@ -187,10 +187,10 @@ func (s *Store) GetInvitation(id string) (*models.WorkspaceInvitation, error) {
|
||||
var acceptedAt *string
|
||||
var createdAt string
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, workspace_id, email, role, invited_by, code, accepted_at, created_at
|
||||
FROM workspace_invitations WHERE id = ?
|
||||
`, id).Scan(
|
||||
`), id).Scan(
|
||||
&inv.ID, &inv.WorkspaceID, &inv.Email, &inv.Role, &inv.InvitedBy,
|
||||
&inv.Code, &acceptedAt, &createdAt,
|
||||
)
|
||||
@@ -212,10 +212,10 @@ func (s *Store) GetInvitationByCode(code string) (*models.WorkspaceInvitation, e
|
||||
var acceptedAt *string
|
||||
var createdAt string
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, workspace_id, email, role, invited_by, code, accepted_at, created_at
|
||||
FROM workspace_invitations WHERE code = ? AND accepted_at IS NULL
|
||||
`, code).Scan(
|
||||
`), code).Scan(
|
||||
&inv.ID, &inv.WorkspaceID, &inv.Email, &inv.Role, &inv.InvitedBy,
|
||||
&inv.Code, &acceptedAt, &createdAt,
|
||||
)
|
||||
@@ -234,7 +234,7 @@ func (s *Store) GetInvitationByCode(code string) (*models.WorkspaceInvitation, e
|
||||
// AcceptInvitation marks an invitation as accepted.
|
||||
func (s *Store) AcceptInvitation(id string) error {
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE workspace_invitations SET accepted_at = ? WHERE id = ?",
|
||||
s.q("UPDATE workspace_invitations SET accepted_at = ? WHERE id = ?"),
|
||||
now(), id,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -246,7 +246,7 @@ func (s *Store) AcceptInvitation(id string) error {
|
||||
// DeleteInvitation removes a pending invitation.
|
||||
func (s *Store) DeleteInvitation(workspaceID, invitationID string) error {
|
||||
result, err := s.db.Exec(
|
||||
"DELETE FROM workspace_invitations WHERE id = ? AND workspace_id = ? AND accepted_at IS NULL",
|
||||
s.q("DELETE FROM workspace_invitations WHERE id = ? AND workspace_id = ? AND accepted_at IS NULL"),
|
||||
invitationID, workspaceID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -261,12 +261,12 @@ func (s *Store) DeleteInvitation(workspaceID, invitationID string) error {
|
||||
|
||||
// ListWorkspaceInvitations returns all invitations for a workspace.
|
||||
func (s *Store) ListWorkspaceInvitations(workspaceID string) ([]models.WorkspaceInvitation, error) {
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT id, workspace_id, email, role, invited_by, code, accepted_at, created_at
|
||||
FROM workspace_invitations
|
||||
WHERE workspace_id = ? AND accepted_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
`, workspaceID)
|
||||
`), workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list workspace invitations: %w", err)
|
||||
}
|
||||
@@ -297,7 +297,7 @@ func (s *Store) backfillWorkspaceOwners() error {
|
||||
// Find the first admin user (if any)
|
||||
var adminID string
|
||||
err := s.db.QueryRow(
|
||||
"SELECT id FROM users WHERE role = 'admin' ORDER BY created_at ASC LIMIT 1",
|
||||
s.q("SELECT id FROM users WHERE role = 'admin' ORDER BY created_at ASC LIMIT 1"),
|
||||
).Scan(&adminID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil // No users yet — nothing to backfill
|
||||
@@ -307,11 +307,11 @@ func (s *Store) backfillWorkspaceOwners() error {
|
||||
}
|
||||
|
||||
// Find workspaces with no members
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT w.id FROM workspaces w
|
||||
WHERE w.deleted_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM workspace_members wm WHERE wm.workspace_id = w.id)
|
||||
`)
|
||||
`))
|
||||
if err != nil {
|
||||
return fmt.Errorf("find ownerless workspaces: %w", err)
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
)
|
||||
|
||||
func (s *Store) ListWorkspaces() ([]models.Workspace, error) {
|
||||
rows, err := s.db.Query(`
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT id, name, slug, description, settings, created_at, updated_at
|
||||
FROM workspaces
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY name ASC
|
||||
`)
|
||||
`))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -69,10 +69,10 @@ func (s *Store) CreateWorkspace(input models.WorkspaceCreate) (*models.Workspace
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
_, err = s.db.Exec(s.q(`
|
||||
INSERT INTO workspaces (id, name, slug, description, settings, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, id, input.Name, finalSlug, input.Description, settings, ts, ts)
|
||||
`), id, input.Name, finalSlug, input.Description, settings, ts, ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert workspace: %w", err)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func (s *Store) uniqueWorkspaceSlug(baseSlug string) (string, error) {
|
||||
slug := baseSlug
|
||||
for i := 2; ; i++ {
|
||||
var count int
|
||||
err := s.db.QueryRow("SELECT COUNT(*) FROM workspaces WHERE slug = ? AND deleted_at IS NULL", slug).Scan(&count)
|
||||
err := s.db.QueryRow(s.q("SELECT COUNT(*) FROM workspaces WHERE slug = ? AND deleted_at IS NULL"), slug).Scan(&count)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -100,11 +100,11 @@ func (s *Store) GetWorkspaceBySlug(slug string) (*models.Workspace, error) {
|
||||
var createdAt, updatedAt string
|
||||
var deletedAt *string
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, name, slug, description, settings, created_at, updated_at, deleted_at
|
||||
FROM workspaces
|
||||
WHERE slug = ? AND deleted_at IS NULL
|
||||
`, slug).Scan(&w.ID, &w.Name, &w.Slug, &w.Description, &w.Settings, &createdAt, &updatedAt, &deletedAt)
|
||||
`), slug).Scan(&w.ID, &w.Name, &w.Slug, &w.Description, &w.Settings, &createdAt, &updatedAt, &deletedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -124,11 +124,11 @@ func (s *Store) GetWorkspaceByID(id string) (*models.Workspace, error) {
|
||||
var createdAt, updatedAt string
|
||||
var deletedAt *string
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, name, slug, description, settings, created_at, updated_at, deleted_at
|
||||
FROM workspaces
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`, id).Scan(&w.ID, &w.Name, &w.Slug, &w.Description, &w.Settings, &createdAt, &updatedAt, &deletedAt)
|
||||
`), id).Scan(&w.ID, &w.Name, &w.Slug, &w.Description, &w.Settings, &createdAt, &updatedAt, &deletedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -178,10 +178,10 @@ func (s *Store) UpdateWorkspace(slug string, input models.WorkspaceUpdate) (*mod
|
||||
w.Settings = settings
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
_, err = s.db.Exec(s.q(`
|
||||
UPDATE workspaces SET name = ?, description = ?, settings = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, w.Name, w.Description, w.Settings, ts, w.ID)
|
||||
`), w.Name, w.Description, w.Settings, ts, w.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -191,10 +191,10 @@ func (s *Store) UpdateWorkspace(slug string, input models.WorkspaceUpdate) (*mod
|
||||
|
||||
func (s *Store) DeleteWorkspace(slug string) error {
|
||||
ts := now()
|
||||
result, err := s.db.Exec(`
|
||||
result, err := s.db.Exec(s.q(`
|
||||
UPDATE workspaces SET deleted_at = ?, updated_at = ?
|
||||
WHERE slug = ? AND deleted_at IS NULL
|
||||
`, ts, ts, slug)
|
||||
`), ts, ts, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -50,7 +50,7 @@ func NewDispatcher(store WebhookStore) *Dispatcher {
|
||||
func (d *Dispatcher) Dispatch(workspaceID, event string, data interface{}) {
|
||||
hooks, err := d.store.ListWebhooks(workspaceID)
|
||||
if err != nil {
|
||||
log.Printf("webhooks: failed to list webhooks for workspace %s: %v", workspaceID, err)
|
||||
slog.Error("failed to list webhooks", "workspace", workspaceID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func (d *Dispatcher) Dispatch(workspaceID, event string, data interface{}) {
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Printf("webhooks: failed to marshal payload: %v", err)
|
||||
slog.Error("failed to marshal webhook payload", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func (d *Dispatcher) deliver(hook models.Webhook, body []byte) {
|
||||
// Defense in depth: re-validate URL before making the request
|
||||
if !d.SkipSSRF {
|
||||
if err := ValidateWebhookURL(hook.URL); err != nil {
|
||||
log.Printf("webhooks: blocked delivery to %s: %v", hook.URL, err)
|
||||
slog.Warn("blocked webhook delivery", "url", hook.URL, "error", err)
|
||||
d.store.UpdateWebhookFailure(hook.ID, true)
|
||||
return
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func (d *Dispatcher) deliver(hook models.Webhook, body []byte) {
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, hook.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Printf("webhooks: failed to create request for %s: %v", hook.URL, err)
|
||||
slog.Error("failed to create webhook request", "url", hook.URL, "error", err)
|
||||
d.store.UpdateWebhookFailure(hook.ID, true)
|
||||
return
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func (d *Dispatcher) deliver(hook models.Webhook, body []byte) {
|
||||
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("webhooks: delivery failed for %s: %v", hook.URL, err)
|
||||
slog.Error("webhook delivery failed", "url", hook.URL, "error", err)
|
||||
d.store.UpdateWebhookFailure(hook.ID, true)
|
||||
return
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func (d *Dispatcher) deliver(hook models.Webhook, body []byte) {
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
d.store.UpdateWebhookFailure(hook.ID, false)
|
||||
} else {
|
||||
log.Printf("webhooks: non-2xx response (%d) from %s", resp.StatusCode, hook.URL)
|
||||
slog.Warn("webhook non-2xx response", "status", resp.StatusCode, "url", hook.URL)
|
||||
d.store.UpdateWebhookFailure(hook.ID, true)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user