Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
159 lines
6.0 KiB
Go
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
|
|
}
|