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,218 @@
|
||||
// Read-only query layer for the Buchhaltungs-Pull-API (see
|
||||
// internal/api/accounting_handlers.go). Deliberately separate from
|
||||
// documents.go's ListDocuments: the accounting view is a machine-to-machine
|
||||
// export with its own reduced projection (no ocr_text, no storage_path, no
|
||||
// content_hash) and keyset pagination over (created_at, id).
|
||||
//
|
||||
// Tenant isolation: every query here takes tenantID as its FIRST parameter and
|
||||
// filters `WHERE d.tenant_id = $1` — there is no variant without it. The
|
||||
// caller (the Bearer-auth middleware) derives that id solely from the resolved
|
||||
// API key, never from the request.
|
||||
//
|
||||
// No permission-group ACL filter is applied: an accounting API key is a
|
||||
// tenant-level machine credential (like the SFTP inbox account), not a user
|
||||
// session. That is why creating one requires domain_admin.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ErrInvalidAccountingCursor is returned when a client-supplied cursor cannot
|
||||
// be decoded. The handler maps this to HTTP 400.
|
||||
var ErrInvalidAccountingCursor = errors.New("storage: invalid accounting cursor")
|
||||
|
||||
// AccountingDocument is the reduced, export-safe projection of a document for
|
||||
// the pull API. storage_path / content_hash / ocr_text are intentionally
|
||||
// absent (GoBD/security: the WORM location is never exposed; the file is only
|
||||
// reachable through the streaming endpoint).
|
||||
type AccountingDocument struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
DocumentDate *time.Time `json:"document_date,omitempty"`
|
||||
DocumentDateScore *float64 `json:"document_date_score,omitempty"`
|
||||
DocTypeID *int64 `json:"doc_type_id,omitempty"`
|
||||
DocType string `json:"doc_type,omitempty"`
|
||||
CorrespondentID *int64 `json:"correspondent_id,omitempty"`
|
||||
Correspondent string `json:"correspondent,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AccountingDocumentFilter holds the (already validated) query parameters of
|
||||
// GET /api/v1/accounting/documents. TenantID is NOT part of it on purpose — it
|
||||
// is passed separately from the API-key context so it can never be overwritten
|
||||
// by a decoded request body/query.
|
||||
type AccountingDocumentFilter struct {
|
||||
// Since/Until bound document_date (inclusive/exclusive respectively).
|
||||
Since *time.Time
|
||||
Until *time.Time
|
||||
// DocTypeID restricts to one document type.
|
||||
DocTypeID *int64
|
||||
// MinDateScore is the confidence quality gate (e.g. 0.75); documents with a
|
||||
// NULL score are excluded as soon as this is set.
|
||||
MinDateScore *float64
|
||||
// Cursor is the opaque keyset cursor from a previous page ("" = first page).
|
||||
Cursor string
|
||||
// Limit is the page size (already clamped by the handler).
|
||||
Limit int
|
||||
}
|
||||
|
||||
// AccountingPage is one page of pull results plus the cursor for the next one.
|
||||
type AccountingPage struct {
|
||||
Documents []AccountingDocument `json:"documents"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// encodeAccountingCursor builds the opaque keyset cursor from the last row of a
|
||||
// page. Format (base64url of) "<unix_nanos>:<id>" — the exact tuple the ORDER
|
||||
// BY / WHERE comparison uses.
|
||||
func encodeAccountingCursor(createdAt time.Time, id int64) string {
|
||||
raw := strconv.FormatInt(createdAt.UTC().UnixNano(), 10) + ":" + strconv.FormatInt(id, 10)
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(raw))
|
||||
}
|
||||
|
||||
// decodeAccountingCursor parses a cursor produced by encodeAccountingCursor.
|
||||
func decodeAccountingCursor(cursor string) (time.Time, int64, error) {
|
||||
b, err := base64.RawURLEncoding.DecodeString(cursor)
|
||||
if err != nil {
|
||||
return time.Time{}, 0, ErrInvalidAccountingCursor
|
||||
}
|
||||
parts := strings.SplitN(string(b), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return time.Time{}, 0, ErrInvalidAccountingCursor
|
||||
}
|
||||
nanos, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, 0, ErrInvalidAccountingCursor
|
||||
}
|
||||
id, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, 0, ErrInvalidAccountingCursor
|
||||
}
|
||||
return time.Unix(0, nanos).UTC(), id, nil
|
||||
}
|
||||
|
||||
// ListAccountingDocuments returns one keyset-paginated page of a tenant's
|
||||
// documents for the pull API, ordered by (created_at, id) ascending so a
|
||||
// consumer can poll incrementally without ever re-reading or skipping rows.
|
||||
// Soft-deleted (trashed) documents are excluded.
|
||||
func (s *Store) ListAccountingDocuments(ctx context.Context, tenantID int64, f AccountingDocumentFilter) (*AccountingPage, error) {
|
||||
limit := f.Limit
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// $1 is always the tenant id — the isolation predicate is not optional.
|
||||
args := []any{tenantID}
|
||||
where := []string{"d.tenant_id = $1", "d.deleted_at IS NULL"}
|
||||
|
||||
if f.Since != nil {
|
||||
args = append(args, *f.Since)
|
||||
where = append(where, fmt.Sprintf("d.document_date >= $%d", len(args)))
|
||||
}
|
||||
if f.Until != nil {
|
||||
args = append(args, *f.Until)
|
||||
where = append(where, fmt.Sprintf("d.document_date < $%d", len(args)))
|
||||
}
|
||||
if f.DocTypeID != nil {
|
||||
args = append(args, *f.DocTypeID)
|
||||
where = append(where, fmt.Sprintf("d.doc_type_id = $%d", len(args)))
|
||||
}
|
||||
if f.MinDateScore != nil {
|
||||
args = append(args, *f.MinDateScore)
|
||||
where = append(where, fmt.Sprintf("d.document_date_score IS NOT NULL AND d.document_date_score >= $%d", len(args)))
|
||||
}
|
||||
if f.Cursor != "" {
|
||||
curTS, curID, err := decodeAccountingCursor(f.Cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, curTS, curID)
|
||||
where = append(where, fmt.Sprintf("(d.created_at, d.id) > ($%d, $%d)", len(args)-1, len(args)))
|
||||
}
|
||||
|
||||
// Fetch one extra row to detect whether a further page exists.
|
||||
args = append(args, limit+1)
|
||||
query := `
|
||||
SELECT d.id, d.title, d.document_date, d.document_date_score,
|
||||
d.doc_type_id, COALESCE(dt.name, COALESCE(d.doc_type, '')),
|
||||
d.correspondent_id, COALESCE(c.name, COALESCE(d.correspondent, '')),
|
||||
d.created_at
|
||||
FROM documents d
|
||||
LEFT JOIN document_types dt ON dt.id = d.doc_type_id AND dt.tenant_id = d.tenant_id
|
||||
LEFT JOIN correspondents c ON c.id = d.correspondent_id AND c.tenant_id = d.tenant_id
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
ORDER BY d.created_at ASC, d.id ASC
|
||||
LIMIT $` + strconv.Itoa(len(args))
|
||||
|
||||
rows, err := s.db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: list accounting documents: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]AccountingDocument, 0, limit)
|
||||
for rows.Next() {
|
||||
var d AccountingDocument
|
||||
if err := rows.Scan(&d.ID, &d.Title, &d.DocumentDate, &d.DocumentDateScore,
|
||||
&d.DocTypeID, &d.DocType, &d.CorrespondentID, &d.Correspondent, &d.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("storage: scan accounting document: %w", err)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("storage: list accounting documents: %w", err)
|
||||
}
|
||||
|
||||
page := &AccountingPage{Documents: out}
|
||||
if len(out) > limit {
|
||||
page.Documents = out[:limit]
|
||||
page.HasMore = true
|
||||
last := page.Documents[limit-1]
|
||||
page.NextCursor = encodeAccountingCursor(last.CreatedAt, last.ID)
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// AccountingFileRef carries the server-side-only information needed to stream a
|
||||
// document's WORM file. The storage path is unexported and reachable only via
|
||||
// StoragePath(), mirroring storage.ResolvedShare, so a handler cannot
|
||||
// accidentally serialise it into a response.
|
||||
type AccountingFileRef struct {
|
||||
DocumentID int64
|
||||
Title string
|
||||
storagePath string
|
||||
}
|
||||
|
||||
// StoragePath returns the WORM path of the file (server-side only).
|
||||
func (r *AccountingFileRef) StoragePath() string { return r.storagePath }
|
||||
|
||||
// GetAccountingDocumentFile resolves a document's WORM file location, scoped to
|
||||
// the tenant of the API key (id AND tenant_id — IDOR guard). Returns
|
||||
// ErrDocumentNotFound for unknown id, foreign tenant and trashed document
|
||||
// alike, so the endpoint never reveals whether a document exists outside the
|
||||
// caller's tenant.
|
||||
func (s *Store) GetAccountingDocumentFile(ctx context.Context, id, tenantID int64) (*AccountingFileRef, error) {
|
||||
var ref AccountingFileRef
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, title, storage_path
|
||||
FROM documents
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
`, id, tenantID).Scan(&ref.DocumentID, &ref.Title, &ref.storagePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrDocumentNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("storage: get accounting document file: %w", err)
|
||||
}
|
||||
return &ref, nil
|
||||
}
|
||||
Reference in New Issue
Block a user