Files
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

133 lines
5.0 KiB
Go

package storage
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ErrNoteNotFound is returned when a tenant-scoped note lookup/delete affects
// zero rows (wrong id, wrong document, or wrong tenant).
var ErrNoteNotFound = errors.New("storage: document note not found")
// ErrNoteForbidden is returned by DeleteDocumentNote when the requester is
// neither the note's author nor a domain admin.
var ErrNoteForbidden = errors.New("storage: not allowed to delete this document note")
// DocumentNote is a free-text comment attached to a document (Paperless-ngx
// inspired). Unlike custom fields (which are structured metadata), a note is
// plain free text with an author and timestamps. Notes are not GoBD documents
// themselves, so they are hard-deleted rather than soft-deleted — but every
// create/delete is still recorded in the audit log for Nachvollziehbarkeit.
type DocumentNote struct {
ID int64 `json:"id"`
DocumentID int64 `json:"document_id"`
AuthorID int64 `json:"author_id"`
Text string `json:"text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// initDocumentNotesSchema creates the document_notes table. Idempotent; wired
// into Store.initSchema after initWorkflowsSchema (see documents.go).
func (s *Store) initDocumentNotesSchema(ctx context.Context) error {
_, err := s.db.Exec(ctx, `
CREATE TABLE IF NOT EXISTS document_notes (
id BIGSERIAL PRIMARY KEY,
document_id BIGINT NOT NULL REFERENCES documents(id),
tenant_id BIGINT NOT NULL,
author_id BIGINT NOT NULL,
text TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_document_notes_document ON document_notes (document_id, tenant_id);
`)
if err != nil {
return fmt.Errorf("storage: create document_notes table: %w", err)
}
return nil
}
// CreateDocumentNote inserts a free-text note on a document. It verifies the
// document exists within the tenant (and is not soft-deleted) before inserting,
// so a note can never be attached to a foreign-tenant document (IDOR guard).
func (s *Store) CreateDocumentNote(ctx context.Context, documentID, tenantID, authorID int64, text string) (*DocumentNote, error) {
var exists bool
if err := s.db.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM documents WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)
`, documentID, tenantID).Scan(&exists); err != nil {
return nil, fmt.Errorf("storage: check document for note: %w", err)
}
if !exists {
return nil, ErrDocumentNotFound
}
var n DocumentNote
err := s.db.QueryRow(ctx, `
INSERT INTO document_notes (document_id, tenant_id, author_id, text)
VALUES ($1, $2, $3, $4)
RETURNING id, document_id, author_id, text, created_at, updated_at
`, documentID, tenantID, authorID, text).Scan(&n.ID, &n.DocumentID, &n.AuthorID, &n.Text, &n.CreatedAt, &n.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("storage: create document note: %w", err)
}
return &n, nil
}
// ListDocumentNotes returns all notes for a document, oldest first, scoped to
// tenant.
func (s *Store) ListDocumentNotes(ctx context.Context, documentID, tenantID int64) ([]DocumentNote, error) {
rows, err := s.db.Query(ctx, `
SELECT id, document_id, author_id, text, created_at, updated_at
FROM document_notes
WHERE document_id = $1 AND tenant_id = $2
ORDER BY created_at ASC
`, documentID, tenantID)
if err != nil {
return nil, fmt.Errorf("storage: list document notes: %w", err)
}
defer rows.Close()
out := make([]DocumentNote, 0)
for rows.Next() {
var n DocumentNote
if err := rows.Scan(&n.ID, &n.DocumentID, &n.AuthorID, &n.Text, &n.CreatedAt, &n.UpdatedAt); err != nil {
return nil, fmt.Errorf("storage: scan document note: %w", err)
}
out = append(out, n)
}
return out, rows.Err()
}
// DeleteDocumentNote hard-deletes a note. Only the note's author or a domain
// admin may delete it. Returns ErrNoteNotFound if the note does not exist for
// the given document/tenant, or ErrNoteForbidden if the requester is not
// permitted (checked before deletion so the caller can return 403 vs 404).
func (s *Store) DeleteDocumentNote(ctx context.Context, id, documentID, tenantID, requesterID int64, requesterIsAdmin bool) error {
var authorID int64
err := s.db.QueryRow(ctx, `
SELECT author_id FROM document_notes WHERE id = $1 AND document_id = $2 AND tenant_id = $3
`, id, documentID, tenantID).Scan(&authorID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNoteNotFound
}
return fmt.Errorf("storage: lookup document note: %w", err)
}
if !requesterIsAdmin && authorID != requesterID {
return ErrNoteForbidden
}
tag, err := s.db.Exec(ctx, `DELETE FROM document_notes WHERE id = $1 AND document_id = $2 AND tenant_id = $3`, id, documentID, tenantID)
if err != nil {
return fmt.Errorf("storage: delete document note: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNoteNotFound
}
return nil
}