Files
archivdms/internal/storage/saved_views.go
T
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

159 lines
6.0 KiB
Go

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
}