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 }