package storage import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/jackc/pgx/v5" ) // ErrSavedViewNotFound is returned when a saved-view lookup/update/delete // affects zero rows (wrong id, wrong tenant, or — for mutations — the caller // is not the view's creator). var ErrSavedViewNotFound = errors.New("storage: saved view not found") // ErrSavedViewForbidden is returned by UpdateSavedView/DeleteSavedView when the // view exists within the tenant but the requester is not its creator (only the // creator may modify a shared view). var ErrSavedViewForbidden = errors.New("storage: not allowed to modify this saved view") // SavedView is a named, reusable search/filter query (Paperless-ngx inspired). // Filters stores the serialized search query verbatim (JSONB) so the client can // re-hydrate it 1:1 into a new search request. A view is private to its creator // unless IsShared is set, in which case it is visible to every user of the // tenant — but still only editable/deletable by its creator. type SavedView struct { ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` UserID int64 `json:"user_id"` Name string `json:"name"` Filters json.RawMessage `json:"filters"` IsShared bool `json:"is_shared"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // initSavedViewsSchema creates the saved_views table. Idempotent; wired into // Store.initSchema after initDocumentNotesSchema (see documents.go). func (s *Store) initSavedViewsSchema(ctx context.Context) error { _, err := s.db.Exec(ctx, ` CREATE TABLE IF NOT EXISTS saved_views ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, name TEXT NOT NULL, filters JSONB NOT NULL, is_shared BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_saved_views_tenant_user ON saved_views (tenant_id, user_id); CREATE INDEX IF NOT EXISTS idx_saved_views_tenant_shared ON saved_views (tenant_id, is_shared) WHERE is_shared; `) if err != nil { return fmt.Errorf("storage: create saved_views table: %w", err) } return nil } // CreateSavedView inserts a new saved view for the given user/tenant and // returns it. func (s *Store) CreateSavedView(ctx context.Context, tenantID, userID int64, name string, filters json.RawMessage, isShared bool) (*SavedView, error) { var v SavedView err := s.db.QueryRow(ctx, ` INSERT INTO saved_views (tenant_id, user_id, name, filters, is_shared) VALUES ($1, $2, $3, $4, $5) RETURNING id, tenant_id, user_id, name, filters, is_shared, created_at, updated_at `, tenantID, userID, name, filters, isShared).Scan( &v.ID, &v.TenantID, &v.UserID, &v.Name, &v.Filters, &v.IsShared, &v.CreatedAt, &v.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("storage: create saved view: %w", err) } return &v, nil } // ListSavedViews returns all views the user may see within the tenant: their // own views plus every view shared tenant-wide (is_shared). Ordered by name. func (s *Store) ListSavedViews(ctx context.Context, tenantID, userID int64) ([]SavedView, error) { rows, err := s.db.Query(ctx, ` SELECT id, tenant_id, user_id, name, filters, is_shared, created_at, updated_at FROM saved_views WHERE tenant_id = $1 AND (user_id = $2 OR is_shared) ORDER BY name ASC, id ASC `, tenantID, userID) if err != nil { return nil, fmt.Errorf("storage: list saved views: %w", err) } defer rows.Close() out := make([]SavedView, 0) for rows.Next() { var v SavedView if err := rows.Scan(&v.ID, &v.TenantID, &v.UserID, &v.Name, &v.Filters, &v.IsShared, &v.CreatedAt, &v.UpdatedAt); err != nil { return nil, fmt.Errorf("storage: scan saved view: %w", err) } out = append(out, v) } return out, rows.Err() } // UpdateSavedView updates a view's name/filters/is_shared. Only the creator may // update it: the WHERE clause matches id + tenant_id + user_id. If the view does // not exist within the tenant, ErrSavedViewNotFound is returned; if it exists // but belongs to another user, ErrSavedViewForbidden is returned (so the caller // can distinguish 404 from 403). func (s *Store) UpdateSavedView(ctx context.Context, id, tenantID, userID int64, name string, filters json.RawMessage, isShared bool) error { tag, err := s.db.Exec(ctx, ` UPDATE saved_views SET name = $4, filters = $5, is_shared = $6, updated_at = now() WHERE id = $1 AND tenant_id = $2 AND user_id = $3 `, id, tenantID, userID, name, filters, isShared) if err != nil { return fmt.Errorf("storage: update saved view: %w", err) } if tag.RowsAffected() == 0 { return s.savedViewMissReason(ctx, id, tenantID, userID) } return nil } // DeleteSavedView deletes a view. Only the creator may delete it (WHERE // id + tenant_id + user_id). Returns ErrSavedViewNotFound / ErrSavedViewForbidden // analogous to UpdateSavedView. func (s *Store) DeleteSavedView(ctx context.Context, id, tenantID, userID int64) error { tag, err := s.db.Exec(ctx, ` DELETE FROM saved_views WHERE id = $1 AND tenant_id = $2 AND user_id = $3 `, id, tenantID, userID) if err != nil { return fmt.Errorf("storage: delete saved view: %w", err) } if tag.RowsAffected() == 0 { return s.savedViewMissReason(ctx, id, tenantID, userID) } return nil } // savedViewMissReason disambiguates a zero-rows mutation: the view either does // not exist for the tenant at all (ErrSavedViewNotFound) or it exists but is // owned by another user (ErrSavedViewForbidden). func (s *Store) savedViewMissReason(ctx context.Context, id, tenantID, userID int64) error { var ownerID int64 err := s.db.QueryRow(ctx, ` SELECT user_id FROM saved_views WHERE id = $1 AND tenant_id = $2 `, id, tenantID).Scan(&ownerID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return ErrSavedViewNotFound } return fmt.Errorf("storage: lookup saved view owner: %w", err) } if ownerID != userID { return ErrSavedViewForbidden } return ErrSavedViewNotFound }