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.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user