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