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,391 @@
|
||||
// Buchhaltungs-Pull-API: a reduced, read-only, machine-to-machine export path
|
||||
// so an accounting system (DATEV-Vorerfassung, Kanzlei-Software, ...) can pull
|
||||
// belegdatum-scored documents out of archivdms without a browser session.
|
||||
//
|
||||
// Two clearly separated halves:
|
||||
//
|
||||
// 1. Key administration — normal JWT-cookie/session endpoints (domain_admin+,
|
||||
// same pattern as retention_rule_handlers.go):
|
||||
//
|
||||
// POST /api/accounting/api-keys create, returns the plaintext key ONCE
|
||||
// GET /api/accounting/api-keys list (label/timestamps only, no key)
|
||||
// DELETE /api/accounting/api-keys/{id} revoke (never hard-deleted)
|
||||
//
|
||||
// 2. The pull endpoints themselves — NOT wrapped in s.auth. They use
|
||||
// s.accountingAuth (Authorization: Bearer <key>) instead:
|
||||
//
|
||||
// GET /api/v1/accounting/documents keyset-paginated metadata
|
||||
// GET /api/v1/accounting/documents/{id}/file streams the WORM file
|
||||
//
|
||||
// TENANT ISOLATION (critical — this is the only non-browser access path):
|
||||
// s.accountingAuth resolves the raw bearer key to a tenant id via
|
||||
// storage.ResolveAccountingAPIKey and puts ONLY that id into the request
|
||||
// context (accountingTenantKey). The pull handlers read the tenant id
|
||||
// exclusively from that context via accountingCtxFromRequest; there is no code
|
||||
// path in which a tenant_id from the query string, a header or a body is
|
||||
// consulted. The store functions they call (ListAccountingDocuments,
|
||||
// GetAccountingDocumentFile) take tenantID as a mandatory first argument and
|
||||
// have no unscoped variant. A document belonging to another tenant is
|
||||
// indistinguishable from a nonexistent one (404, never 403).
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
accountingTenantKey contextKey = "accounting_tenant_id"
|
||||
accountingKeyIDKey contextKey = "accounting_key_id"
|
||||
)
|
||||
|
||||
// accountingMaxLimit caps the page size a client may request.
|
||||
const accountingMaxLimit = 500
|
||||
|
||||
// accountingDefaultLimit is used when no (or an invalid) limit is given.
|
||||
const accountingDefaultLimit = 100
|
||||
|
||||
// --- key administration (session-authenticated, domain_admin+) ---
|
||||
|
||||
// createAccountingKeyRequest is the JSON body for POST /api/accounting/api-keys.
|
||||
type createAccountingKeyRequest struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// createAccountingKeyResponse is the ONLY place the plaintext key is ever
|
||||
// returned. It is not persisted anywhere in plaintext and cannot be retrieved
|
||||
// again.
|
||||
type createAccountingKeyResponse struct {
|
||||
Key storage.AccountingAPIKey `json:"key"`
|
||||
// PlaintextKey is shown exactly once — the caller must store it now.
|
||||
PlaintextKey string `json:"plaintext_key"`
|
||||
}
|
||||
|
||||
// handleCreateAccountingAPIKey handles POST /api/accounting/api-keys (domain_admin+).
|
||||
func (s *Server) handleCreateAccountingAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
var req createAccountingKeyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
label := strings.TrimSpace(req.Label)
|
||||
if label == "" {
|
||||
writeError(w, http.StatusBadRequest, "label is required")
|
||||
return
|
||||
}
|
||||
|
||||
userID := sess.UserID
|
||||
key, plaintext, err := s.store.CreateAccountingAPIKey(r.Context(), *sess.TenantID, label, &userID)
|
||||
if err != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventAccountingKeyCreated, Username: sess.Username, IPAddress: s.remoteIP(r),
|
||||
TenantID: sess.TenantID, Success: false,
|
||||
Detail: "accounting_key_create label:" + label + " err:" + err.Error(),
|
||||
})
|
||||
writeError(w, http.StatusInternalServerError, "create accounting api key failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventAccountingKeyCreated, Username: sess.Username, IPAddress: s.remoteIP(r),
|
||||
TenantID: sess.TenantID, Success: true,
|
||||
Detail: "accounting_key_create id:" + strconv.FormatInt(key.ID, 10) + " label:" + label,
|
||||
})
|
||||
writeJSON(w, http.StatusCreated, createAccountingKeyResponse{Key: *key, PlaintextKey: plaintext})
|
||||
}
|
||||
|
||||
// handleListAccountingAPIKeys handles GET /api/accounting/api-keys (domain_admin+).
|
||||
// Never returns the plaintext key or its hash.
|
||||
func (s *Server) handleListAccountingAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
keys, err := s.store.ListAccountingAPIKeys(r.Context(), *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list accounting api keys failed")
|
||||
return
|
||||
}
|
||||
if keys == nil {
|
||||
keys = []storage.AccountingAPIKey{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, keys)
|
||||
}
|
||||
|
||||
// handleRevokeAccountingAPIKey handles DELETE /api/accounting/api-keys/{id}
|
||||
// (domain_admin+). Revoke only — the row stays so the audit trail of past
|
||||
// pulls remains resolvable.
|
||||
func (s *Server) handleRevokeAccountingAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.store.RevokeAccountingAPIKey(r.Context(), id, *sess.TenantID); err != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventAccountingKeyRevoked, Username: sess.Username, IPAddress: s.remoteIP(r),
|
||||
TenantID: sess.TenantID, Success: false,
|
||||
Detail: "accounting_key_revoke id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(),
|
||||
})
|
||||
if errors.Is(err, storage.ErrAccountingKeyNotFound) {
|
||||
writeError(w, http.StatusNotFound, "accounting api key not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "revoke accounting api key failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventAccountingKeyRevoked, Username: sess.Username, IPAddress: s.remoteIP(r),
|
||||
TenantID: sess.TenantID, Success: true,
|
||||
Detail: "accounting_key_revoke id:" + strconv.FormatInt(id, 10),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||
}
|
||||
|
||||
// --- bearer-key middleware for the pull endpoints ---
|
||||
|
||||
// accountingAuth is the API-key middleware for the pull endpoints. It is
|
||||
// deliberately separate from s.authMiddleware (JWT cookie): no session, no
|
||||
// role, no user — just a tenant-scoped machine credential.
|
||||
//
|
||||
// It puts the tenant id resolved FROM THE KEY into the request context. This is
|
||||
// the single source of truth for tenant scoping downstream; handlers must never
|
||||
// read a tenant id from the request itself.
|
||||
func (s *Server) accountingAuth(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.remoteIP(r)
|
||||
// Per-IP rate limit blunts key guessing on this unauthenticated-until-
|
||||
// resolved path (same limiter type as the public share endpoints).
|
||||
if !s.accountingLimiter.allow(ip) {
|
||||
writeError(w, http.StatusTooManyRequests, "too many requests")
|
||||
return
|
||||
}
|
||||
rawKey := extractBearerToken(r)
|
||||
if rawKey == "" {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
writeError(w, http.StatusUnauthorized, "missing bearer api key")
|
||||
return
|
||||
}
|
||||
tenantID, keyID, err := s.store.ResolveAccountingAPIKey(r.Context(), rawKey)
|
||||
if err != nil {
|
||||
if !errors.Is(err, storage.ErrAccountingKeyNotFound) {
|
||||
s.logger.Error("accounting api key resolve failed", "err", err)
|
||||
}
|
||||
// Unknown, revoked and broken keys are indistinguishable.
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: ip,
|
||||
Success: false, Detail: "accounting_auth rejected path:" + r.URL.Path,
|
||||
})
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
writeError(w, http.StatusUnauthorized, "invalid api key")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), accountingTenantKey, tenantID)
|
||||
ctx = context.WithValue(ctx, accountingKeyIDKey, keyID)
|
||||
h(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
// accountingCtxFromRequest returns the tenant id and key id that
|
||||
// accountingAuth resolved. ok is false only if the handler was somehow reached
|
||||
// without the middleware — handlers then must refuse to do anything.
|
||||
func accountingCtxFromRequest(ctx context.Context) (tenantID, keyID int64, ok bool) {
|
||||
t, tOK := ctx.Value(accountingTenantKey).(int64)
|
||||
k, kOK := ctx.Value(accountingKeyIDKey).(int64)
|
||||
if !tOK || !kOK {
|
||||
return 0, 0, false
|
||||
}
|
||||
return t, k, true
|
||||
}
|
||||
|
||||
// --- pull endpoints (bearer-key authenticated) ---
|
||||
|
||||
// handleAccountingListDocuments handles
|
||||
// GET /api/v1/accounting/documents?since=&until=&doc_type_id=&min_date_score=&cursor=&limit=
|
||||
//
|
||||
// since/until are dates (YYYY-MM-DD or RFC3339) bounding document_date;
|
||||
// min_date_score gates on the belegdatum confidence (e.g. 0.75); cursor/limit
|
||||
// drive keyset pagination over (created_at, id). Any tenant_id query parameter
|
||||
// is ignored — scoping comes from the API key alone.
|
||||
func (s *Server) handleAccountingListDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, keyID, ok := accountingCtxFromRequest(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "invalid api key")
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
|
||||
filter := storage.AccountingDocumentFilter{
|
||||
Cursor: q.Get("cursor"),
|
||||
Limit: accountingDefaultLimit,
|
||||
}
|
||||
if v := strings.TrimSpace(q.Get("since")); v != "" {
|
||||
t, err := parseAccountingDate(v)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid since (expected YYYY-MM-DD or RFC3339)")
|
||||
return
|
||||
}
|
||||
filter.Since = &t
|
||||
}
|
||||
if v := strings.TrimSpace(q.Get("until")); v != "" {
|
||||
t, err := parseAccountingDate(v)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid until (expected YYYY-MM-DD or RFC3339)")
|
||||
return
|
||||
}
|
||||
filter.Until = &t
|
||||
}
|
||||
if v := strings.TrimSpace(q.Get("doc_type_id")); v != "" {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid doc_type_id")
|
||||
return
|
||||
}
|
||||
filter.DocTypeID = &id
|
||||
}
|
||||
if v := strings.TrimSpace(q.Get("min_date_score")); v != "" {
|
||||
score, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil || score < 0 || score > 1 {
|
||||
writeError(w, http.StatusBadRequest, "invalid min_date_score (expected 0..1)")
|
||||
return
|
||||
}
|
||||
filter.MinDateScore = &score
|
||||
}
|
||||
if v := strings.TrimSpace(q.Get("limit")); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
if n > accountingMaxLimit {
|
||||
n = accountingMaxLimit
|
||||
}
|
||||
filter.Limit = n
|
||||
}
|
||||
|
||||
page, err := s.store.ListAccountingDocuments(r.Context(), tenantID, filter)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrInvalidAccountingCursor) {
|
||||
writeError(w, http.StatusBadRequest, "invalid cursor")
|
||||
return
|
||||
}
|
||||
s.logger.Error("accounting list failed", "tenant_id", tenantID, "err", err)
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: s.remoteIP(r),
|
||||
TenantID: &tenantID, Success: false,
|
||||
Detail: "accounting_pull list key:" + strconv.FormatInt(keyID, 10) + " err:" + err.Error(),
|
||||
})
|
||||
writeError(w, http.StatusInternalServerError, "list documents failed")
|
||||
return
|
||||
}
|
||||
if page.Documents == nil {
|
||||
page.Documents = []storage.AccountingDocument{}
|
||||
}
|
||||
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: s.remoteIP(r),
|
||||
TenantID: &tenantID, Success: true,
|
||||
Detail: "accounting_pull list key:" + strconv.FormatInt(keyID, 10) +
|
||||
" count:" + strconv.Itoa(len(page.Documents)) +
|
||||
" range:" + accountingIDRange(page.Documents),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, page)
|
||||
}
|
||||
|
||||
// handleAccountingDocumentFile handles GET /api/v1/accounting/documents/{id}/file.
|
||||
// Streams the archived WORM file through the handler — storage_path is never
|
||||
// exposed. Scoped to the API key's tenant; a foreign or unknown document both
|
||||
// yield 404 (no existence leak, mirroring handleGetDocumentFile).
|
||||
func (s *Server) handleAccountingDocumentFile(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, keyID, ok := accountingCtxFromRequest(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "invalid api key")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
|
||||
ref, err := s.store.GetAccountingDocumentFile(r.Context(), id, tenantID)
|
||||
if err != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: s.remoteIP(r),
|
||||
TenantID: &tenantID, DocumentID: strconv.FormatInt(id, 10), Success: false,
|
||||
Detail: "accounting_pull file key:" + strconv.FormatInt(keyID, 10) + " not_found",
|
||||
})
|
||||
writeError(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(ref.StoragePath())
|
||||
if err != nil {
|
||||
s.logger.Error("accounting file open failed", "document_id", ref.DocumentID, "tenant_id", tenantID, "err", err)
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: s.remoteIP(r),
|
||||
TenantID: &tenantID, DocumentID: strconv.FormatInt(id, 10), Success: false,
|
||||
Detail: "accounting_pull file key:" + strconv.FormatInt(keyID, 10) + " open_failed",
|
||||
})
|
||||
writeError(w, http.StatusInternalServerError, "file unavailable")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: s.remoteIP(r),
|
||||
TenantID: &tenantID, DocumentID: strconv.FormatInt(id, 10), Success: true,
|
||||
Detail: "accounting_pull file key:" + strconv.FormatInt(keyID, 10),
|
||||
})
|
||||
|
||||
ext := filepath.Ext(ref.StoragePath())
|
||||
w.Header().Set("Content-Type", detectMimeType("", ext, ref.StoragePath()))
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+safeDownloadName(ref.Title, ext)+"\"")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if _, err := io.Copy(w, f); err != nil {
|
||||
s.logger.Warn("accounting file stream interrupted", "document_id", ref.DocumentID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// parseAccountingDate accepts either a plain date (YYYY-MM-DD, interpreted as
|
||||
// UTC midnight) or a full RFC3339 timestamp.
|
||||
func parseAccountingDate(v string) (time.Time, error) {
|
||||
if t, err := time.Parse("2006-01-02", v); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// accountingIDRange renders "first-last" document ids of a page for the audit
|
||||
// Detail, so a later GoBD audit can reconstruct what a pull actually returned.
|
||||
func accountingIDRange(docs []storage.AccountingDocument) string {
|
||||
if len(docs) == 0 {
|
||||
return "-"
|
||||
}
|
||||
return strconv.FormatInt(docs[0].ID, 10) + "-" + strconv.FormatInt(docs[len(docs)-1].ID, 10)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/auth"
|
||||
"archivdms/internal/storage"
|
||||
"archivdms/internal/userstore"
|
||||
)
|
||||
|
||||
// akteACLUserID returns the ACL user filter for the caller: nil for
|
||||
// domain_admin/superadmin (see every document in the tenant), a non-nil user ID
|
||||
// for role 'user' (filtered against document_visibility). Mirrors the logic in
|
||||
// handleListDocuments.
|
||||
func akteACLUserID(sess *auth.Session) *int64 {
|
||||
if auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
|
||||
return nil
|
||||
}
|
||||
uid := sess.UserID
|
||||
return &uid
|
||||
}
|
||||
|
||||
func (s *Server) handleListAkten(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
akten, err := s.store.ListAkten(r.Context(), *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list akten failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, akten)
|
||||
}
|
||||
|
||||
type createAkteRequest struct {
|
||||
Titel string `json:"titel"`
|
||||
Beschreibung string `json:"beschreibung"`
|
||||
CorrespondentID *int64 `json:"correspondent_id"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateAkte(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
ctx := r.Context()
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
var req createAkteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
titel := strings.TrimSpace(req.Titel)
|
||||
if titel == "" {
|
||||
writeError(w, http.StatusBadRequest, "titel is required")
|
||||
return
|
||||
}
|
||||
// Guard against cross-tenant references: the correspondent must belong to
|
||||
// the same tenant.
|
||||
if req.CorrespondentID != nil && !s.taxonomyEntityBelongsToTenant(ctx, "correspondents", *req.CorrespondentID, *sess.TenantID) {
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventAkteCreate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "invalid_correspondent_id"})
|
||||
writeError(w, http.StatusBadRequest, "invalid correspondent_id")
|
||||
return
|
||||
}
|
||||
|
||||
akte, err := s.store.CreateAkte(ctx, *sess.TenantID, titel, strings.TrimSpace(req.Beschreibung), req.CorrespondentID, sess.UserID)
|
||||
if err != nil {
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventAkteCreate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: err.Error()})
|
||||
writeError(w, http.StatusInternalServerError, "create akte failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventAkteCreate, Username: sess.Username, TenantID: sess.TenantID, Success: true, Detail: "akte_id=" + strconv.FormatInt(akte.ID, 10)})
|
||||
writeJSON(w, http.StatusCreated, akte)
|
||||
}
|
||||
|
||||
// akteDetailResponse is the GET /api/akten/{id} payload: the akte plus its
|
||||
// ACL-filtered documents.
|
||||
type akteDetailResponse struct {
|
||||
*storage.Akte
|
||||
Documents []storage.Document `json:"documents"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetAkte(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
ctx := r.Context()
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || sess.TenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid akte id")
|
||||
return
|
||||
}
|
||||
akte, err := s.store.GetAkte(ctx, id, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "akte not found")
|
||||
return
|
||||
}
|
||||
docs, err := s.store.ListAkteDocuments(ctx, id, *sess.TenantID, akteACLUserID(sess))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list akte documents failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, akteDetailResponse{Akte: akte, Documents: docs})
|
||||
}
|
||||
|
||||
type updateAkteRequest struct {
|
||||
Titel string `json:"titel"`
|
||||
Beschreibung string `json:"beschreibung"`
|
||||
CorrespondentID *int64 `json:"correspondent_id"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateAkte(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
ctx := r.Context()
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || sess.TenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid akte id")
|
||||
return
|
||||
}
|
||||
var req updateAkteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
titel := strings.TrimSpace(req.Titel)
|
||||
if titel == "" {
|
||||
writeError(w, http.StatusBadRequest, "titel is required")
|
||||
return
|
||||
}
|
||||
if req.CorrespondentID != nil && !s.taxonomyEntityBelongsToTenant(ctx, "correspondents", *req.CorrespondentID, *sess.TenantID) {
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventAkteUpdate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "invalid_correspondent_id"})
|
||||
writeError(w, http.StatusBadRequest, "invalid correspondent_id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.UpdateAkte(ctx, id, *sess.TenantID, titel, strings.TrimSpace(req.Beschreibung), req.CorrespondentID); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
msg := "update akte failed"
|
||||
if errors.Is(err, storage.ErrAkteNotFound) {
|
||||
status = http.StatusNotFound
|
||||
msg = "akte not found"
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventAkteUpdate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
akte, err := s.store.GetAkte(ctx, id, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "akte not found")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventAkteUpdate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true})
|
||||
writeJSON(w, http.StatusOK, akte)
|
||||
}
|
||||
|
||||
func (s *Server) handleCloseAkte(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
ctx := r.Context()
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || sess.TenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid akte id")
|
||||
return
|
||||
}
|
||||
if err := s.store.CloseAkte(ctx, id, *sess.TenantID); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
msg := "close akte failed"
|
||||
if errors.Is(err, storage.ErrAkteNotFound) {
|
||||
status = http.StatusNotFound
|
||||
msg = "akte not found"
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventAkteClose, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
akte, err := s.store.GetAkte(ctx, id, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "akte not found")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventAkteClose, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true})
|
||||
writeJSON(w, http.StatusOK, akte)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAkte(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
ctx := r.Context()
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || sess.TenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid akte id")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteAkte(ctx, id, *sess.TenantID); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
msg := "delete akte failed"
|
||||
if errors.Is(err, storage.ErrAkteNotFound) {
|
||||
status = http.StatusNotFound
|
||||
msg = "akte not found"
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventAkteDelete, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventAkteDelete, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// setDocumentAkteRequest uses a pointer so an explicit JSON null removes the
|
||||
// assignment while an omitted field is rejected (see handleSetDocumentAkte).
|
||||
type setDocumentAkteRequest struct {
|
||||
AkteID *int64 `json:"akte_id"`
|
||||
}
|
||||
|
||||
// handleSetDocumentAkte assigns (or clears) a document's akte membership
|
||||
// (PUT /api/documents/{id}/akte, body {"akte_id": number|null}). A null value
|
||||
// removes the assignment. The akte is not part of the document ACL, so
|
||||
// SetDocumentAkte only re-syncs the search index. Audited as
|
||||
// EventAkteDocumentAdd (assign) or EventAkteDocumentRemove (clear).
|
||||
func (s *Server) handleSetDocumentAkte(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
ctx := r.Context()
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || sess.TenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
var req setDocumentAkteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
event := audit.EventAkteDocumentRemove
|
||||
if req.AkteID != nil {
|
||||
event = audit.EventAkteDocumentAdd
|
||||
}
|
||||
|
||||
// Ownership check: the document must belong to the caller's tenant.
|
||||
if _, err := s.store.GetDocument(ctx, id, *sess.TenantID); err != nil {
|
||||
s.audlog.Log(audit.Entry{EventType: event, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "document_not_found"})
|
||||
writeError(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
// Guard against cross-tenant references: the akte must belong to the same
|
||||
// tenant (nil means "remove", which needs no lookup).
|
||||
if req.AkteID != nil {
|
||||
if _, err := s.store.GetAkte(ctx, *req.AkteID, *sess.TenantID); err != nil {
|
||||
s.audlog.Log(audit.Entry{EventType: event, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "invalid_akte_id"})
|
||||
writeError(w, http.StatusBadRequest, "invalid akte_id")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.store.SetDocumentAkte(ctx, id, *sess.TenantID, req.AkteID); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
msg := "set akte failed"
|
||||
if errors.Is(err, storage.ErrDocumentNotFound) {
|
||||
status = http.StatusNotFound
|
||||
msg = "document not found"
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: event, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
doc, err := s.store.GetDocument(ctx, id, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
detail := "akte_id=cleared"
|
||||
if req.AkteID != nil {
|
||||
detail = "akte_id=" + strconv.FormatInt(*req.AkteID, 10)
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: event, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true, Detail: detail})
|
||||
writeJSON(w, http.StatusOK, doc)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
)
|
||||
|
||||
func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
entries, total, err := s.audlog.Query(audit.QueryFilter{
|
||||
TenantID: sess.TenantID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "audit query failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "total": total})
|
||||
}
|
||||
|
||||
// handleDocumentAuditLog returns the audit trail scoped to a single document
|
||||
// (GET /api/documents/{id}/audit). Unlike handleAuditLog (domain_admin+, full
|
||||
// tenant log) this is available to every authenticated user, but only after an
|
||||
// ownership/ACL check: GetDocument filters WHERE tenant_id (and the document
|
||||
// ACL), so a caller who may not see the document gets a 404 and never its
|
||||
// history. The document_id filter uses the exact same string format
|
||||
// (strconv.FormatInt(id, 10)) that document_handlers.go writes into the audit
|
||||
// entries, otherwise the filter would match nothing.
|
||||
func (s *Server) handleDocumentAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || sess.TenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
// ACL/tenant check: only callers who may see the document may see its history.
|
||||
if _, err := s.store.GetDocument(r.Context(), id, *sess.TenantID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
entries, total, err := s.audlog.Query(audit.QueryFilter{
|
||||
TenantID: sess.TenantID,
|
||||
DocumentID: strconv.FormatInt(id, 10),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "audit query failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "total": total})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
)
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
ip := s.remoteIP(r)
|
||||
token, user, err := s.authMgr.LoginFrom(r.Context(), req.Username, req.Password, ip)
|
||||
if err != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventLogin,
|
||||
Username: req.Username,
|
||||
IPAddress: ip,
|
||||
Success: false,
|
||||
Detail: "invalid_credentials",
|
||||
})
|
||||
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: s.cfg.SecureCookies,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 8 * 60 * 60,
|
||||
})
|
||||
|
||||
_ = s.users.UpdateLastLogin(user.ID)
|
||||
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventLogin,
|
||||
Username: user.Username,
|
||||
IPAddress: ip,
|
||||
TenantID: user.TenantID,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": user})
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
user, err := s.users.GetByID(sess.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
token := ""
|
||||
if c, err := r.Cookie(sessionCookieName); err == nil {
|
||||
token = c.Value
|
||||
}
|
||||
if token == "" {
|
||||
token = extractBearerToken(r)
|
||||
}
|
||||
if token != "" {
|
||||
_ = s.authMgr.Logout(token)
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventLogout,
|
||||
Username: sess.Username,
|
||||
IPAddress: s.remoteIP(r),
|
||||
TenantID: sess.TenantID,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"})
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
// Classification-template ("Klassifizierungsvorlagen") HTTP handlers (see
|
||||
// internal/storage/classification_templates.go +
|
||||
// classification_templates_apply.go):
|
||||
//
|
||||
// GET/POST /api/classification-templates GET/PUT/DELETE /api/classification-templates/{id}
|
||||
// PUT /api/classification-templates/{id}/tags
|
||||
// PUT /api/classification-templates/{id}/field-defaults
|
||||
// POST /api/documents/{id}/apply-template
|
||||
//
|
||||
// Template administration (CRUD + tag / field-default bulk replace) requires
|
||||
// domain_admin (s.authAdmin). Applying a template to a document is a normal
|
||||
// working action and only requires an authenticated tenant context (s.auth).
|
||||
// Ownership is enforced in the store layer (id+tenant_id). Every mutation is
|
||||
// audit-logged, including failures.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
type templateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
DocTypeID *int64 `json:"doc_type_id"`
|
||||
RetainYears *int `json:"retain_years"`
|
||||
Active *bool `json:"active"`
|
||||
TitleTemplate *string `json:"title_template"`
|
||||
}
|
||||
|
||||
// handleListTemplates handles GET /api/classification-templates (optional
|
||||
// ?doc_type_id= filter).
|
||||
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
var docTypeID *int64
|
||||
if raw := r.URL.Query().Get("doc_type_id"); raw != "" {
|
||||
id, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid doc_type_id")
|
||||
return
|
||||
}
|
||||
docTypeID = &id
|
||||
}
|
||||
tmpls, err := s.store.ListTemplates(r.Context(), *sess.TenantID, docTypeID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list classification templates failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tmpls)
|
||||
}
|
||||
|
||||
// handleGetTemplate handles GET /api/classification-templates/{id} (resolved).
|
||||
func (s *Server) handleGetTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
tmpl, err := s.store.GetTemplate(r.Context(), id, *sess.TenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrClassificationTemplateNotFound) {
|
||||
writeError(w, http.StatusNotFound, "classification template not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "get classification template failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tmpl)
|
||||
}
|
||||
|
||||
// handleCreateTemplate handles POST /api/classification-templates (domain_admin+).
|
||||
func (s *Server) handleCreateTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
var req templateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
active := true
|
||||
if req.Active != nil {
|
||||
active = *req.Active
|
||||
}
|
||||
if req.TitleTemplate != nil {
|
||||
if err := storage.ValidateTitleTemplate(*req.TitleTemplate); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
tmpl, err := s.store.CreateTemplate(r.Context(), *sess.TenantID, storage.CreateTemplateRequest{
|
||||
Name: req.Name, Description: req.Description, DocTypeID: req.DocTypeID,
|
||||
RetainYears: req.RetainYears, Active: active, CreatedBy: &sess.UserID,
|
||||
TitleTemplate: req.TitleTemplate,
|
||||
})
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, storage.ErrDuplicateTemplateName) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventTemplateCreate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "template_create err:" + err.Error()})
|
||||
writeError(w, status, "create classification template failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventTemplateCreate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "template_create id:" + strconv.FormatInt(tmpl.ID, 10) + " name:" + tmpl.Name,
|
||||
})
|
||||
writeJSON(w, http.StatusCreated, tmpl)
|
||||
}
|
||||
|
||||
// handleUpdateTemplate handles PUT /api/classification-templates/{id} (domain_admin+).
|
||||
func (s *Server) handleUpdateTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var req templateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
active := true
|
||||
if req.Active != nil {
|
||||
active = *req.Active
|
||||
}
|
||||
if req.TitleTemplate != nil {
|
||||
if err := storage.ValidateTitleTemplate(*req.TitleTemplate); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
err = s.store.UpdateTemplate(r.Context(), id, *sess.TenantID, storage.UpdateTemplateRequest{
|
||||
Name: req.Name, Description: req.Description, DocTypeID: req.DocTypeID,
|
||||
RetainYears: req.RetainYears, Active: active,
|
||||
TitleTemplate: req.TitleTemplate,
|
||||
})
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, storage.ErrClassificationTemplateNotFound) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, storage.ErrDuplicateTemplateName) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "template_update id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
|
||||
writeError(w, status, "update classification template failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "template_update id:" + strconv.FormatInt(id, 10),
|
||||
})
|
||||
tmpl, err := s.store.GetTemplate(r.Context(), id, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "reload classification template failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tmpl)
|
||||
}
|
||||
|
||||
// handleDeleteTemplate handles DELETE /api/classification-templates/{id} (domain_admin+).
|
||||
func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteTemplate(r.Context(), id, *sess.TenantID); err != nil {
|
||||
status := http.StatusNotFound
|
||||
if !errors.Is(err, storage.ErrClassificationTemplateNotFound) {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventTemplateDelete, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "template_delete id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
|
||||
writeError(w, status, "delete classification template failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventTemplateDelete, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "template_delete id:" + strconv.FormatInt(id, 10),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
type templateTagsRequest struct {
|
||||
TagIDs []int64 `json:"tag_ids"`
|
||||
}
|
||||
|
||||
// handleSetTemplateTags handles PUT /api/classification-templates/{id}/tags (domain_admin+).
|
||||
func (s *Server) handleSetTemplateTags(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var req templateTagsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetTemplateTags(r.Context(), id, *sess.TenantID, req.TagIDs); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, storage.ErrClassificationTemplateNotFound) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, storage.ErrTaxonomyNotFound) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "template_tags_set id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
|
||||
writeError(w, status, "set template tags failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "template_tags_set id:" + strconv.FormatInt(id, 10) + " count:" + strconv.Itoa(len(req.TagIDs)),
|
||||
})
|
||||
tmpl, err := s.store.GetTemplate(r.Context(), id, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "reload classification template failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tmpl)
|
||||
}
|
||||
|
||||
type templateFieldDefaultRequest struct {
|
||||
FieldID int64 `json:"field_id"`
|
||||
ValueText *string `json:"value_text"`
|
||||
ValueNumber *float64 `json:"value_number"`
|
||||
ValueDate *string `json:"value_date"`
|
||||
ValueBool *bool `json:"value_bool"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
// handleSetTemplateFieldDefaults handles PUT
|
||||
// /api/classification-templates/{id}/field-defaults (bulk replace, domain_admin+).
|
||||
func (s *Server) handleSetTemplateFieldDefaults(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var reqs []templateFieldDefaultRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqs); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body (expected array)")
|
||||
return
|
||||
}
|
||||
defaults := make([]storage.TemplateFieldDefaultInput, 0, len(reqs))
|
||||
for _, d := range reqs {
|
||||
defaults = append(defaults, storage.TemplateFieldDefaultInput{
|
||||
FieldID: d.FieldID, ValueText: d.ValueText, ValueNumber: d.ValueNumber,
|
||||
ValueDate: d.ValueDate, ValueBool: d.ValueBool, Overwrite: d.Overwrite,
|
||||
})
|
||||
}
|
||||
if err := s.store.SetTemplateFieldDefaults(r.Context(), id, *sess.TenantID, defaults); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
msg := "set template field defaults failed"
|
||||
if errors.Is(err, storage.ErrClassificationTemplateNotFound) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, storage.ErrCustomFieldNotFound) {
|
||||
status = http.StatusBadRequest
|
||||
msg = "unknown custom field"
|
||||
} else if strings.Contains(err.Error(), "invalid date") || strings.Contains(err.Error(), "not in enum options") {
|
||||
status = http.StatusBadRequest
|
||||
msg = err.Error()
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "template_field_defaults_set id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "template_field_defaults_set id:" + strconv.FormatInt(id, 10) + " count:" + strconv.Itoa(len(defaults)),
|
||||
})
|
||||
tmpl, err := s.store.GetTemplate(r.Context(), id, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "reload classification template failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tmpl)
|
||||
}
|
||||
|
||||
type applyTemplateRequest struct {
|
||||
TemplateID int64 `json:"template_id"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
// handleApplyTemplate handles POST /api/documents/{id}/apply-template. Any
|
||||
// authenticated tenant user may apply a template (normal working action). With
|
||||
// dry_run=true it only previews (no writes). A rejected retain_until shortening
|
||||
// (RetainUntilBlocked) is still audit-logged for GoBD traceability.
|
||||
func (s *Server) handleApplyTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
var req applyTemplateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.TemplateID == 0 {
|
||||
writeError(w, http.StatusBadRequest, "template_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
docRef := strconv.FormatInt(docID, 10)
|
||||
if req.DryRun {
|
||||
res, err := s.store.PreviewApplyTemplate(r.Context(), docID, req.TemplateID, *sess.TenantID)
|
||||
if err != nil {
|
||||
s.writeTemplateApplyError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := s.store.ApplyTemplate(r.Context(), docID, req.TemplateID, *sess.TenantID, req.Overwrite)
|
||||
if err != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventTemplateApplied, Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: docRef, Success: false, Detail: "template_apply template:" + strconv.FormatInt(req.TemplateID, 10) + " err:" + err.Error(),
|
||||
})
|
||||
s.writeTemplateApplyError(w, err)
|
||||
return
|
||||
}
|
||||
detail := "template_apply template:" + strconv.FormatInt(req.TemplateID, 10) +
|
||||
" tags_added:" + strconv.Itoa(len(res.TagsToAdd)) +
|
||||
" fields_set:" + strconv.Itoa(len(res.FieldsToSet)) +
|
||||
" fields_overwritten:" + strconv.Itoa(len(res.FieldsOverwritten))
|
||||
if res.RetainUntilBlocked {
|
||||
detail += " retain_until_shortening_rejected"
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventTemplateApplied, Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: docRef, Success: true, Detail: detail,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// writeTemplateApplyError maps store errors from the apply/preview path to HTTP
|
||||
// status codes.
|
||||
func (s *Server) writeTemplateApplyError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrDocumentNotFound):
|
||||
writeError(w, http.StatusNotFound, "document not found")
|
||||
case errors.Is(err, storage.ErrClassificationTemplateNotFound):
|
||||
writeError(w, http.StatusNotFound, "classification template not found")
|
||||
case errors.Is(err, storage.ErrRequiredFieldMissing):
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "apply template failed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
// GoBD-Verfahrensdokumentation: Entwurfs-Generator.
|
||||
//
|
||||
// GET /api/compliance/procedure-documentation[?tenant_id=N]
|
||||
//
|
||||
// Erzeugt live aus dem aktuellen DB-Stand einen Markdown-Baustein einer
|
||||
// GoBD-Verfahrensdokumentation für GENAU EINEN Mandanten (kein Caching, kein
|
||||
// Vermischen mehrerer Mandanten). Konzept/Gliederung siehe
|
||||
// .claude/agent-memory/retention-compliance/project_gobd_verfahrensdokumentation.md
|
||||
//
|
||||
// Auth (Muster wie retention_rule_handlers.go): domain_admin+ (s.authAdmin) für
|
||||
// den EIGENEN Mandanten. Ein superadmin darf zusätzlich per ?tenant_id=N einen
|
||||
// fremden Mandanten exportieren; für alle anderen Rollen ist ein abweichender
|
||||
// tenant_id-Parameter ein 403. Sämtliche Queries sind strikt auf die eine
|
||||
// aufgelöste tenant_id gefiltert (applikationsseitige Mandantentrennung, kein
|
||||
// Postgres-RLS).
|
||||
//
|
||||
// Das Ergebnis ist ausdrücklich ein ENTWURF und kein rechtsverbindliches
|
||||
// Fertigdokument — der Hinweis steht als erste Zeile im Dokument.
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
"archivdms/internal/userstore"
|
||||
)
|
||||
|
||||
// handleProcedureDocumentation handles GET /api/compliance/procedure-documentation.
|
||||
func (s *Server) handleProcedureDocumentation(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil && sess.Role != userstore.RoleSuperAdmin {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
|
||||
// Ziel-Mandant auflösen: Default ist der eigene Mandant. Ein expliziter
|
||||
// ?tenant_id= ist nur für superadmin erlaubt (Cross-Tenant), für alle
|
||||
// anderen nur, wenn er dem eigenen Mandanten entspricht.
|
||||
tenantID := int64(0)
|
||||
if sess.TenantID != nil {
|
||||
tenantID = *sess.TenantID
|
||||
}
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("tenant_id")); raw != "" {
|
||||
requested, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || requested <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid tenant_id")
|
||||
return
|
||||
}
|
||||
if sess.Role != userstore.RoleSuperAdmin && requested != tenantID {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventComplianceExport, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: false, Detail: "procedure_doc cross-tenant denied requested:" + raw,
|
||||
})
|
||||
writeError(w, http.StatusForbidden, "cross-tenant export requires superadmin")
|
||||
return
|
||||
}
|
||||
tenantID = requested
|
||||
}
|
||||
if tenantID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "tenant_id required")
|
||||
return
|
||||
}
|
||||
|
||||
tenantName := "Mandant " + strconv.FormatInt(tenantID, 10)
|
||||
tenantSlug := strconv.FormatInt(tenantID, 10)
|
||||
if s.tenantStore != nil {
|
||||
t, err := s.tenantStore.GetByID(r.Context(), tenantID)
|
||||
if err != nil || t == nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventComplianceExport, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: false, Detail: "procedure_doc tenant lookup failed tenant:" + strconv.FormatInt(tenantID, 10),
|
||||
})
|
||||
writeError(w, http.StatusNotFound, "tenant not found")
|
||||
return
|
||||
}
|
||||
tenantName = t.Name
|
||||
if t.Slug != "" {
|
||||
tenantSlug = t.Slug
|
||||
}
|
||||
}
|
||||
|
||||
md, err := s.buildProcedureDocumentation(r.Context(), tenantID, tenantName)
|
||||
if err != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventComplianceExport, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: false, Detail: "procedure_doc tenant:" + strconv.FormatInt(tenantID, 10) + " err:" + err.Error(),
|
||||
})
|
||||
writeError(w, http.StatusInternalServerError, "generate procedure documentation failed")
|
||||
return
|
||||
}
|
||||
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventComplianceExport, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "procedure_doc generated tenant:" + strconv.FormatInt(tenantID, 10),
|
||||
})
|
||||
|
||||
filename := fmt.Sprintf("verfahrensdokumentation-entwurf-%s-%s.md",
|
||||
safeFilenamePart(tenantSlug), time.Now().Format("2006-01-02"))
|
||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(md))
|
||||
}
|
||||
|
||||
// safeFilenamePart reduces a tenant slug to [a-z0-9-] so it can never break out
|
||||
// of the Content-Disposition filename.
|
||||
func safeFilenamePart(in string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(in) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '-' || r == '_':
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if out == "" {
|
||||
return "mandant"
|
||||
}
|
||||
if len(out) > 60 {
|
||||
out = out[:60]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildProcedureDocumentation assembles the Markdown draft for one tenant.
|
||||
// Every store call below is tenant-scoped; nothing is queried tenant-wide.
|
||||
func (s *Server) buildProcedureDocumentation(ctx context.Context, tenantID int64, tenantName string) (string, error) {
|
||||
now := time.Now()
|
||||
|
||||
rules, err := s.store.ListRetentionRules(ctx, tenantID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list retention rules: %w", err)
|
||||
}
|
||||
docTypes, err := s.store.ListTaxonomyEntities(ctx, "document_types", tenantID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list document types: %w", err)
|
||||
}
|
||||
tags, err := s.store.ListTaxonomyEntities(ctx, "tags", tenantID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list tags: %w", err)
|
||||
}
|
||||
groups, err := s.store.ListPermissionGroups(ctx, tenantID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list permission groups: %w", err)
|
||||
}
|
||||
workflows, err := s.store.ListWorkflows(ctx, tenantID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list workflows: %w", err)
|
||||
}
|
||||
templates, err := s.store.ListTemplates(ctx, tenantID, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list classification templates: %w", err)
|
||||
}
|
||||
stats, err := s.store.ComplianceStatsForTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("compliance stats: %w", err)
|
||||
}
|
||||
|
||||
docTypeName := map[int64]string{}
|
||||
for _, dt := range docTypes {
|
||||
docTypeName[dt.ID] = dt.Name
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// --- Kopf / Entwurfskennzeichnung -----------------------------------
|
||||
b.WriteString("> **ENTWURF** — automatisch generierter Baustein einer GoBD-Verfahrensdokumentation, Stand " +
|
||||
now.Format("02.01.2006 15:04:05 MST") + ".\n" +
|
||||
"> Ersetzt keine rechtliche Prüfung, muss um Organisationsbeschreibung/Verantwortlichkeiten/Backup-Notfallkonzept " +
|
||||
"ergänzt und von fachkundiger Stelle geprüft werden.\n\n")
|
||||
|
||||
b.WriteString("# Verfahrensdokumentation (Entwurf) — " + tenantName + "\n\n")
|
||||
b.WriteString("| | |\n|---|---|\n")
|
||||
b.WriteString("| Mandant | " + mdCell(tenantName) + " |\n")
|
||||
b.WriteString("| Mandanten-ID | " + strconv.FormatInt(tenantID, 10) + " |\n")
|
||||
b.WriteString("| Stand der Generierung | " + now.Format("02.01.2006 15:04:05 MST") + " |\n")
|
||||
b.WriteString("| System | archivdms (Dokumentenmanagementsystem) |\n")
|
||||
b.WriteString("| Aktive Dokumente | " + strconv.FormatInt(stats.Documents, 10) + " |\n")
|
||||
b.WriteString("| Davon mit Aufbewahrungsfrist (retain_until) | " + strconv.FormatInt(stats.DocumentsWithRetain, 10) + " |\n")
|
||||
b.WriteString("| Dokumente im Papierkorb | " + strconv.FormatInt(stats.DocumentsInTrash, 10) + " |\n\n")
|
||||
|
||||
b.WriteString("Dieses Dokument beschreibt ausschließlich die im System hinterlegte Konfiguration des oben " +
|
||||
"genannten Mandanten. Daten anderer Mandanten sind nicht enthalten (mandantengetrennte Auswertung).\n\n")
|
||||
|
||||
// --- 1. Aufbewahrungsfristen ----------------------------------------
|
||||
b.WriteString("## 1. Aufbewahrungsfristen\n\n")
|
||||
b.WriteString("Aufbewahrungsfristen werden als Regeln je Dokumenttyp gepflegt. Eine Regel ohne Dokumenttyp " +
|
||||
"gilt als Mandanten-Default mit niedrigster Präzedenz; eine dokumenttyp-spezifische Regel hat Vorrang. " +
|
||||
"Aus Fristbeginn (Trigger) und Frist berechnet das System je Dokument ein Datum `retain_until`; " +
|
||||
"bis zu diesem Datum ist eine endgültige Löschung technisch blockiert.\n\n")
|
||||
b.WriteString("Fristbeginn (Trigger-Typen): `document_date` = Belegdatum (ersatzweise Uploaddatum), " +
|
||||
"`upload_date` = Uploaddatum, `fixed_date` = fixes Stichtagsdatum, " +
|
||||
"`event` = ereignisgesteuert (wird nicht automatisch berechnet, erfordert manuelle Fristsetzung).\n\n")
|
||||
if len(rules) == 0 {
|
||||
b.WriteString("**Es sind derzeit keine Aufbewahrungsregeln konfiguriert.** [MANUELL ZU ERGÄNZEN: " +
|
||||
"gesetzliche Fristen (z. B. § 147 AO, § 257 HGB) je Dokumentart benennen und im System hinterlegen.]\n\n")
|
||||
} else {
|
||||
b.WriteString("| Regel | Geltungsbereich | Fristbeginn | Frist | Rechtsgrundlage | Löschfreigabe nötig | DSGVO-Konflikt | Aktiv |\n")
|
||||
b.WriteString("|---|---|---|---|---|---|---|---|\n")
|
||||
for _, ru := range rules {
|
||||
scope := "Mandanten-Default (alle Dokumenttypen ohne eigene Regel)"
|
||||
if ru.DocTypeID != nil {
|
||||
if n, ok := docTypeName[*ru.DocTypeID]; ok {
|
||||
scope = "Dokumenttyp: " + n
|
||||
} else {
|
||||
scope = "Dokumenttyp-ID " + strconv.FormatInt(*ru.DocTypeID, 10)
|
||||
}
|
||||
}
|
||||
trigger := ru.TriggerType
|
||||
if ru.TriggerReference != "" {
|
||||
trigger += " (" + ru.TriggerReference + ")"
|
||||
}
|
||||
b.WriteString("| " + mdCell(ru.Name) + " | " + mdCell(scope) + " | " + mdCell(trigger) + " | " +
|
||||
mdCell(retentionPeriodText(ru)) + " | " + mdCell(orDash(ru.LegalBasis)) + " | " +
|
||||
jaNein(ru.RequiresApprovalForDestroy) + " | " + jaNein(ru.DSGVOConflict) + " | " +
|
||||
jaNein(ru.Active) + " |\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("[MANUELL ZU ERGÄNZEN: Prüfung, ob die hinterlegten Fristen den für dieses Unternehmen " +
|
||||
"einschlägigen handels- und steuerrechtlichen Vorgaben entsprechen.]\n\n")
|
||||
|
||||
// --- 2. Zugriffsschutz ----------------------------------------------
|
||||
b.WriteString("## 2. Zugriffsschutz und Berechtigungen\n\n")
|
||||
b.WriteString("### 2.1 Rollenmodell\n\n")
|
||||
b.WriteString("- `superadmin` — mandantenübergreifende Systemverwaltung (Anlage von Mandanten).\n")
|
||||
b.WriteString("- `domain_admin` — Administration innerhalb des eigenen Mandanten: Benutzer, Berechtigungsgruppen, " +
|
||||
"Aufbewahrungsregeln, Workflows, Klassifizierungsvorlagen, Bestätigung endgültiger Löschungen.\n")
|
||||
b.WriteString("- `user` — Erfassen, Suchen und Bearbeiten von Dokumenten im Rahmen der erteilten Berechtigungen.\n\n")
|
||||
b.WriteString("Die Anmeldung erfolgt passwortbasiert (bcrypt-Hash, Kostenfaktor 12) bzw. optional gegen ein " +
|
||||
"Verzeichnis (LDAP); die Sitzung wird über ein signiertes, nicht per JavaScript auslesbares Sitzungs-Cookie " +
|
||||
"geführt. Mandantentrennung erfolgt applikationsseitig: jede Datenbankabfrage ist auf den Mandanten des " +
|
||||
"angemeldeten Benutzers eingeschränkt.\n\n")
|
||||
|
||||
if s.users != nil {
|
||||
users, err := s.users.ListByTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list users: %w", err)
|
||||
}
|
||||
roleCount := map[string]int{}
|
||||
for _, u := range users {
|
||||
if !u.Active {
|
||||
roleCount["(inaktiv)"]++
|
||||
continue
|
||||
}
|
||||
roleCount[u.Role]++
|
||||
}
|
||||
keys := make([]string, 0, len(roleCount))
|
||||
for k := range roleCount {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
b.WriteString("Benutzerbestand dieses Mandanten: ")
|
||||
if len(keys) == 0 {
|
||||
b.WriteString("keine Benutzer erfasst.\n\n")
|
||||
} else {
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s: %d", k, roleCount[k]))
|
||||
}
|
||||
b.WriteString(strings.Join(parts, ", ") + ".\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("### 2.2 Berechtigungsgruppen\n\n")
|
||||
if len(groups) == 0 {
|
||||
b.WriteString("Es sind keine Berechtigungsgruppen angelegt; der Zugriff wird ausschließlich über das " +
|
||||
"Rollenmodell gesteuert. [MANUELL ZU ERGÄNZEN: Begründung, falls keine feinere Zugriffssteuerung " +
|
||||
"erforderlich ist.]\n\n")
|
||||
} else {
|
||||
b.WriteString("| Gruppe | Mitglieder |\n|---|---|\n")
|
||||
for _, g := range groups {
|
||||
members, err := s.store.ListGroupMembersDetailed(ctx, g.ID, tenantID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list group members: %w", err)
|
||||
}
|
||||
names := make([]string, 0, len(members))
|
||||
for _, m := range members {
|
||||
names = append(names, m.Username)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
names = append(names, "—")
|
||||
}
|
||||
b.WriteString("| " + mdCell(g.Name) + " | " + mdCell(strings.Join(names, ", ")) + " |\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString("### 2.3 Berechtigungsebenen\n\n")
|
||||
b.WriteString("Der Zugriff auf ein einzelnes Dokument ergibt sich aus drei Ebenen; die speziellere Ebene " +
|
||||
"überschreibt die allgemeinere:\n\n")
|
||||
b.WriteString("1. **Dokumenttyp-Berechtigung** (Grundeinstellung je Dokumenttyp)\n")
|
||||
b.WriteString("2. **Schlagwort-Berechtigung** (Grants über die Tags eines Dokuments)\n")
|
||||
b.WriteString("3. **Einzeldokument-Berechtigung** (Übersteuerung für ein konkretes Dokument, inkl. Entzug/`deny`)\n\n")
|
||||
b.WriteString(fmt.Sprintf("Aktuell vergeben: %d Dokumenttyp-Berechtigungen, %d Schlagwort-Berechtigungen, "+
|
||||
"%d Einzeldokument-Berechtigungen bei %d Berechtigungsgruppen.\n\n",
|
||||
stats.DocTypeGrants, stats.TagGrants, stats.DocumentGrants, stats.PermissionGroups))
|
||||
|
||||
// Dokumenttyp-Grants im Detail.
|
||||
dtRows := 0
|
||||
var dtBuf strings.Builder
|
||||
for _, dt := range docTypes {
|
||||
grants, err := s.store.ListDocumentTypeGrants(ctx, tenantID, dt.ID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list document type grants: %w", err)
|
||||
}
|
||||
for _, g := range grants {
|
||||
dtBuf.WriteString("| " + mdCell(dt.Name) + " | " + mdCell(g.GroupName) + " | " + mdCell(g.Access) + " |\n")
|
||||
dtRows++
|
||||
}
|
||||
}
|
||||
if dtRows > 0 {
|
||||
b.WriteString("**Dokumenttyp-Berechtigungen**\n\n| Dokumenttyp | Gruppe | Zugriff |\n|---|---|---|\n")
|
||||
b.WriteString(dtBuf.String() + "\n")
|
||||
}
|
||||
|
||||
tagRows := 0
|
||||
var tagBuf strings.Builder
|
||||
for _, tg := range tags {
|
||||
grants, err := s.store.ListTagGrants(ctx, tenantID, tg.ID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list tag grants: %w", err)
|
||||
}
|
||||
for _, g := range grants {
|
||||
tagBuf.WriteString("| " + mdCell(tg.Name) + " | " + mdCell(g.GroupName) + " | " + mdCell(g.Access) + " |\n")
|
||||
tagRows++
|
||||
}
|
||||
}
|
||||
if tagRows > 0 {
|
||||
b.WriteString("**Schlagwort-Berechtigungen**\n\n| Schlagwort | Gruppe | Zugriff |\n|---|---|---|\n")
|
||||
b.WriteString(tagBuf.String() + "\n")
|
||||
}
|
||||
if stats.DocumentGrants > 0 {
|
||||
b.WriteString(fmt.Sprintf("Zusätzlich bestehen %d dokumentbezogene Einzelberechtigungen. "+
|
||||
"Jede Änderung daran ist im Änderungsprotokoll nachvollziehbar.\n\n", stats.DocumentGrants))
|
||||
}
|
||||
|
||||
// --- 3. Löschkonzept -------------------------------------------------
|
||||
b.WriteString("## 3. Löschkonzept und Unveränderbarkeit\n\n")
|
||||
b.WriteString("### 3.1 Unveränderbarkeit der Ablage (WORM)\n\n")
|
||||
b.WriteString("Archivierte Dokumente werden im Dateisystem nach dem WORM-Prinzip abgelegt " +
|
||||
"(*write once, read many*): die Datei wird einmalig geschrieben und anschließend schreibgeschützt gesetzt " +
|
||||
"(Dateirechte 0440, nur lesend). Der Ablagepfad wird nach Mandant, Jahr und Monat gegliedert; der Dateiname " +
|
||||
"ist der SHA-256-Hash des Inhalts. Dieser Prüfwert wird zusätzlich in der Datenbank geführt und dient als " +
|
||||
"fälschungssensibler Fingerabdruck: eine nachträgliche inhaltliche Veränderung würde den Hash verändern und " +
|
||||
"wäre damit erkennbar. Ein erneuter Upload desselben Inhalts wird über den Hash als Dublette erkannt.\n\n")
|
||||
b.WriteString("### 3.2 Zweistufiges Löschverfahren\n\n")
|
||||
b.WriteString("Ein Dokument kann nicht unmittelbar aus dem Archiv entfernt werden. Der Ablauf ist zweistufig " +
|
||||
"und folgt dem Vier-Augen-Prinzip:\n\n")
|
||||
b.WriteString("1. **Papierkorb (Soft-Delete):** Das Dokument wird als gelöscht markiert (`deleted_at`, " +
|
||||
"`deleted_by`), bleibt aber gespeichert und wiederherstellbar.\n")
|
||||
b.WriteString("2. **Löschantrag:** Ein Benutzer beantragt die endgültige Löschung (Status `pending`).\n")
|
||||
b.WriteString("3. **Bestätigung durch eine zweite Person:** Ein Administrator (`domain_admin`) bestätigt den " +
|
||||
"Antrag. Eine Bestätigung durch dieselbe Person, die den Antrag gestellt hat, wird technisch " +
|
||||
"zurückgewiesen (Vier-Augen-Prinzip).\n")
|
||||
b.WriteString("4. **Fristprüfung:** Besteht noch eine laufende Aufbewahrungsfrist (`retain_until` in der " +
|
||||
"Zukunft), wird die Löschung blockiert (Status `blocked_retention`).\n")
|
||||
b.WriteString("5. **Ausführung:** Erst danach wird die WORM-Datei entfernt (Status `executed`). Der " +
|
||||
"Löschvorgang wird protokolliert (Antragsteller, Bestätigender, Zeitpunkte, Titel, Inhalts-Hash, " +
|
||||
"Fristzustand) — es verbleibt ein Nachweis über die erfolgte Löschung.\n\n")
|
||||
b.WriteString("Ein Antrag kann bis zur Bestätigung zurückgezogen werden (Status `cancelled`).\n\n")
|
||||
if len(stats.DeleteRequestsByStat) > 0 {
|
||||
statuses := make([]string, 0, len(stats.DeleteRequestsByStat))
|
||||
for k := range stats.DeleteRequestsByStat {
|
||||
statuses = append(statuses, k)
|
||||
}
|
||||
sort.Strings(statuses)
|
||||
b.WriteString("Bisherige Löschanträge dieses Mandanten:\n\n| Status | Anzahl |\n|---|---|\n")
|
||||
for _, st := range statuses {
|
||||
b.WriteString("| " + mdCell(st) + " | " + strconv.FormatInt(stats.DeleteRequestsByStat[st], 10) + " |\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
b.WriteString("Für diesen Mandanten wurden bislang keine endgültigen Löschungen beantragt.\n\n")
|
||||
}
|
||||
|
||||
// --- 4. Erfassungsautomatisierung ------------------------------------
|
||||
b.WriteString("## 4. Erfassung und automatisierte Verarbeitung\n\n")
|
||||
b.WriteString("Dokumente gelangen über den Weg des Uploads über die Weboberfläche oder über eine " +
|
||||
"mandantenbezogene SFTP-Ablage (Posteingangsverzeichnis) in das System. Nach der Übernahme wird der " +
|
||||
"Dokumenteninhalt maschinell ausgelesen (Texterkennung/OCR), ein Prüfwert gebildet und das Dokument in " +
|
||||
"die revisionssichere Ablage überführt. Die Verarbeitung erfolgt über eine Warteschlange; der " +
|
||||
"Verarbeitungsstatus je Dokument ist im System einsehbar.\n\n")
|
||||
|
||||
b.WriteString("### 4.1 Regeln zur automatischen Zuordnung (Workflows)\n\n")
|
||||
if len(workflows) == 0 {
|
||||
b.WriteString("Es sind keine Workflow-Regeln konfiguriert; die Verschlagwortung erfolgt manuell " +
|
||||
"bzw. über die Mustererkennung der Stammdaten.\n\n")
|
||||
} else {
|
||||
b.WriteString("| Regel | Auslöser | Aktiv | Priorität |\n|---|---|---|---|\n")
|
||||
for _, wf := range workflows {
|
||||
b.WriteString("| " + mdCell(wf.Name) + " | " + mdCell(wf.TriggerType) + " | " +
|
||||
jaNein(wf.Enabled) + " | " + strconv.Itoa(wf.Priority) + " |\n")
|
||||
}
|
||||
b.WriteString("\nJede Ausführung einer Workflow-Regel wird protokolliert (Regel, Dokument, Treffer, " +
|
||||
"angewandte Aktionen), sodass die maschinelle Zuordnung nachvollziehbar bleibt.\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("### 4.2 Klassifizierungsvorlagen\n\n")
|
||||
if len(templates) == 0 {
|
||||
b.WriteString("Es sind keine Klassifizierungsvorlagen hinterlegt.\n\n")
|
||||
} else {
|
||||
b.WriteString("| Vorlage | Dokumenttyp | Aufbewahrung (Jahre) | Aktiv | Beschreibung |\n|---|---|---|---|---|\n")
|
||||
for _, t := range templates {
|
||||
dt := "—"
|
||||
if t.DocTypeID != nil {
|
||||
if n, ok := docTypeName[*t.DocTypeID]; ok {
|
||||
dt = n
|
||||
} else {
|
||||
dt = "Dokumenttyp-ID " + strconv.FormatInt(*t.DocTypeID, 10)
|
||||
}
|
||||
}
|
||||
ry := "—"
|
||||
if t.RetainYears != nil {
|
||||
ry = strconv.Itoa(*t.RetainYears)
|
||||
}
|
||||
b.WriteString("| " + mdCell(t.Name) + " | " + mdCell(dt) + " | " + ry + " | " +
|
||||
jaNein(t.Active) + " | " + mdCell(orDash(t.Description)) + " |\n")
|
||||
}
|
||||
b.WriteString("\nDie Anwendung einer Vorlage auf ein Dokument wird protokolliert. Eine spätere Änderung " +
|
||||
"einer Vorlage wirkt nicht rückwirkend auf bereits klassifizierte Dokumente.\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("### 4.3 Stammdaten der Indizierung\n\n")
|
||||
b.WriteString(fmt.Sprintf("Für diesen Mandanten sind %d Dokumenttypen und %d Schlagworte gepflegt. "+
|
||||
"Dokumenttypen und Schlagworte können mit Erkennungsmustern versehen werden, über die eine Zuordnung "+
|
||||
"beim Einlesen automatisch vorgeschlagen bzw. gesetzt wird.\n\n", len(docTypes), len(tags)))
|
||||
if len(docTypes) > 0 {
|
||||
names := make([]string, 0, len(docTypes))
|
||||
for _, dt := range docTypes {
|
||||
names = append(names, dt.Name)
|
||||
}
|
||||
b.WriteString("Dokumenttypen: " + strings.Join(names, ", ") + "\n\n")
|
||||
}
|
||||
|
||||
// --- 5. Nachvollziehbarkeit ------------------------------------------
|
||||
b.WriteString("## 5. Nachvollziehbarkeit (Änderungsprotokoll)\n\n")
|
||||
b.WriteString("Das System führt ein fortschreibendes, nur ergänzbares Änderungsprotokoll (Audit-Log). " +
|
||||
"Bestehende Protokolleinträge können weder verändert noch gelöscht werden; entsprechende " +
|
||||
"Datenbankoperationen werden auf Datenbankebene unterbunden. Jeder Eintrag enthält Zeitstempel, " +
|
||||
"Ereignisart, Benutzername, IP-Adresse, betroffenes Dokument, Erfolg/Misserfolg sowie eine " +
|
||||
"Detailangabe. Auch fehlgeschlagene Versuche werden protokolliert.\n\n")
|
||||
b.WriteString("Protokollierte Ereignisarten (Auszug):\n\n")
|
||||
b.WriteString("- **Anmeldung/Sitzung:** Anmeldung, Abmeldung, fehlgeschlagene Anmeldung, Verzeichnisanmeldung (LDAP)\n")
|
||||
b.WriteString("- **Dokumente:** Anlage, Änderung, erneute Verarbeitung, Seitentrennung, Zuordnung von " +
|
||||
"Dokumenttyp/Korrespondent/Belegdatum, Notizen\n")
|
||||
b.WriteString("- **Löschung:** Verschieben in den Papierkorb, Wiederherstellung, Löschantrag, Bestätigung, " +
|
||||
"Ausführung, Ablehnung wegen laufender Aufbewahrungsfrist\n")
|
||||
b.WriteString("- **Aufbewahrung:** Anlage/Änderung/Löschung von Aufbewahrungsregeln, Anwendung der Fristenläufe\n")
|
||||
b.WriteString("- **Berechtigungen:** Änderungen an Berechtigungen und Gruppen, Benutzerverwaltung\n")
|
||||
b.WriteString("- **Automatisierung:** Workflow-Ausführungen, Anwendung von Klassifizierungsvorlagen, " +
|
||||
"maschinelle Metadatenvorschläge\n")
|
||||
b.WriteString("- **Weitergabe:** Erstellung, Widerruf und Abruf von Freigabelinks\n")
|
||||
b.WriteString("- **Schnittstellen:** SFTP-Zugangsdaten und SFTP-Anmeldungen, Konfigurationsänderungen\n")
|
||||
b.WriteString("- **Compliance:** Erzeugung dieser Verfahrensdokumentation\n\n")
|
||||
b.WriteString("Das Protokoll ist für Administratoren einsehbar und je Dokument filterbar.\n\n")
|
||||
|
||||
// --- 6. Manuell zu ergänzende Kapitel --------------------------------
|
||||
b.WriteString("## 6. Allgemeine Beschreibung des Unternehmens und der Organisation\n\n")
|
||||
b.WriteString("[MANUELL ZU ERGÄNZEN: Unternehmensgegenstand, Aufbau- und Ablauforganisation, welche " +
|
||||
"Belegarten anfallen, welche Vorsysteme (z. B. Kasse, Warenwirtschaft, Buchhaltung) bestehen und wie " +
|
||||
"diese mit dem Archiv zusammenwirken.]\n\n")
|
||||
|
||||
b.WriteString("## 7. Verantwortliche Personen und Vertretungsregelung\n\n")
|
||||
b.WriteString("[MANUELL ZU ERGÄNZEN: Namentlich Verantwortliche für Archivierung, Berechtigungsvergabe, " +
|
||||
"Löschfreigabe und Systembetrieb; Vertretungsregelung; Arbeitsanweisungen und deren Bekanntgabe an die " +
|
||||
"Mitarbeitenden.]\n\n")
|
||||
|
||||
b.WriteString("## 8. Technische Systemdokumentation, Server-, Backup- und Notfallkonzept\n\n")
|
||||
b.WriteString("[MANUELL ZU ERGÄNZEN: eingesetzte Hardware/Server, Standort und Betreiber, Betriebssystem- " +
|
||||
"und Datenbankstand, Datensicherungsverfahren (Umfang, Häufigkeit, Aufbewahrung der Sicherungen, " +
|
||||
"Auslagerung), Rücksicherungstests, Notfall- und Wiederanlaufplan, Verfahren bei Systemwechsel/Migration " +
|
||||
"und Sicherstellung der Lesbarkeit über die gesamte Aufbewahrungsdauer.]\n\n")
|
||||
|
||||
b.WriteString("## 9. Änderungshistorie dieser Verfahrensdokumentation\n\n")
|
||||
b.WriteString("[MANUELL ZU ERGÄNZEN: Versionsstände, Datum, Bearbeiter und Anlass der Änderung. Der " +
|
||||
"vorliegende Entwurf gibt den Systemstand vom " + now.Format("02.01.2006 15:04:05 MST") +
|
||||
" wieder und wird bei jedem Export neu erzeugt.]\n\n")
|
||||
|
||||
b.WriteString("---\n\n")
|
||||
b.WriteString("*Automatisch erzeugter Entwurf aus dem archivdms-Systemstand. Kein rechtsverbindliches " +
|
||||
"Fertigdokument — vor Verwendung gegenüber Dritten (z. B. im Rahmen einer Betriebsprüfung) durch eine " +
|
||||
"fachkundige Stelle prüfen und um die als [MANUELL ZU ERGÄNZEN] gekennzeichneten Abschnitte vervollständigen.*\n")
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// retentionPeriodText renders the retention period of a rule in German prose.
|
||||
func retentionPeriodText(ru storage.RetentionRule) string {
|
||||
parts := []string{}
|
||||
if ru.RetentionYears != nil && *ru.RetentionYears > 0 {
|
||||
parts = append(parts, strconv.Itoa(*ru.RetentionYears)+" Jahre")
|
||||
}
|
||||
if ru.RetentionDays != nil && *ru.RetentionDays > 0 {
|
||||
parts = append(parts, strconv.Itoa(*ru.RetentionDays)+" Tage")
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "nicht gesetzt"
|
||||
}
|
||||
return strings.Join(parts, " + ")
|
||||
}
|
||||
|
||||
// mdCell escapes the characters that would break a Markdown table cell.
|
||||
func mdCell(in string) string {
|
||||
out := strings.ReplaceAll(in, "|", "\\|")
|
||||
out = strings.ReplaceAll(out, "\r", " ")
|
||||
out = strings.ReplaceAll(out, "\n", " ")
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
// orDash returns "—" for an empty string.
|
||||
func orDash(in string) string {
|
||||
if strings.TrimSpace(in) == "" {
|
||||
return "—"
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// jaNein renders a bool in German.
|
||||
func jaNein(b bool) string {
|
||||
if b {
|
||||
return "ja"
|
||||
}
|
||||
return "nein"
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// Custom-fields HTTP handlers (see internal/storage/custom_fields.go):
|
||||
//
|
||||
// GET/POST /api/custom-fields PATCH/DELETE /api/custom-fields/{id}
|
||||
// GET/PUT /api/document-types/{id}/fields
|
||||
// GET/PUT /api/documents/{id}/fields
|
||||
//
|
||||
// Definition create/update/delete require domain_admin (s.authAdmin); listing
|
||||
// and value-setting require an authenticated tenant context (s.auth).
|
||||
// Ownership is enforced in the store layer (id+tenant_id). Every mutation is
|
||||
// audit-logged, including failures, using the document lifecycle event types.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
type customFieldRequest struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
FieldType string `json:"field_type"`
|
||||
EnumOptions []string `json:"enum_options"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// handleListCustomFields handles GET /api/custom-fields.
|
||||
func (s *Server) handleListCustomFields(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
defs, err := s.store.ListCustomFieldDefs(r.Context(), *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list custom fields failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, defs)
|
||||
}
|
||||
|
||||
// handleCreateCustomField handles POST /api/custom-fields (domain_admin+).
|
||||
func (s *Server) handleCreateCustomField(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
var req customFieldRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Label == "" || req.FieldType == "" {
|
||||
writeError(w, http.StatusBadRequest, "name, label and field_type are required")
|
||||
return
|
||||
}
|
||||
|
||||
def, err := s.store.CreateCustomFieldDef(r.Context(), *sess.TenantID, storage.CustomFieldDefRequest{
|
||||
Name: req.Name, Label: req.Label, FieldType: req.FieldType,
|
||||
EnumOptions: req.EnumOptions, Currency: req.Currency,
|
||||
})
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, storage.ErrDuplicateCustomFieldName) {
|
||||
status = http.StatusConflict
|
||||
} else if strings.Contains(err.Error(), "invalid field_type") {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "custom_field_create err:" + err.Error()})
|
||||
writeError(w, status, "create custom field failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "custom_field_create id:" + strconv.FormatInt(def.ID, 10) + " name:" + def.Name,
|
||||
})
|
||||
writeJSON(w, http.StatusCreated, def)
|
||||
}
|
||||
|
||||
// handleUpdateCustomField handles PATCH /api/custom-fields/{id} (domain_admin+).
|
||||
func (s *Server) handleUpdateCustomField(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var req customFieldRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Label == "" {
|
||||
writeError(w, http.StatusBadRequest, "label is required")
|
||||
return
|
||||
}
|
||||
|
||||
def, err := s.store.UpdateCustomFieldDef(r.Context(), id, *sess.TenantID, req.Label, req.EnumOptions, req.Currency)
|
||||
if err != nil {
|
||||
status := http.StatusNotFound
|
||||
if !errors.Is(err, storage.ErrCustomFieldNotFound) {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "custom_field_update id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
|
||||
writeError(w, status, "update custom field failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "custom_field_update id:" + strconv.FormatInt(def.ID, 10),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, def)
|
||||
}
|
||||
|
||||
// handleDeleteCustomField handles DELETE /api/custom-fields/{id} (domain_admin+).
|
||||
func (s *Server) handleDeleteCustomField(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteCustomFieldDef(r.Context(), id, *sess.TenantID); err != nil {
|
||||
status := http.StatusNotFound
|
||||
msg := "delete custom field failed"
|
||||
if errors.Is(err, storage.ErrCustomFieldInUse) {
|
||||
status = http.StatusConflict
|
||||
msg = "custom field still has values"
|
||||
} else if !errors.Is(err, storage.ErrCustomFieldNotFound) {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "custom_field_delete id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "custom_field_delete id:" + strconv.FormatInt(id, 10),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// --- document-type field assignments ---
|
||||
|
||||
type docTypeFieldAssignmentRequest struct {
|
||||
FieldID int64 `json:"field_id"`
|
||||
Required bool `json:"required"`
|
||||
Visible bool `json:"visible"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// handleListDocumentTypeFields handles GET /api/document-types/{id}/fields.
|
||||
func (s *Server) handleListDocumentTypeFields(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docTypeID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document type id")
|
||||
return
|
||||
}
|
||||
fields, err := s.store.ListDocumentTypeFields(r.Context(), docTypeID, *sess.TenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrTaxonomyNotFound) {
|
||||
writeError(w, http.StatusNotFound, "document type not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "list document type fields failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, fields)
|
||||
}
|
||||
|
||||
// handleSetDocumentTypeFields handles PUT /api/document-types/{id}/fields
|
||||
// (bulk replace, domain_admin+).
|
||||
func (s *Server) handleSetDocumentTypeFields(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docTypeID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document type id")
|
||||
return
|
||||
}
|
||||
var reqs []docTypeFieldAssignmentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqs); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body (expected array)")
|
||||
return
|
||||
}
|
||||
assignments := make([]storage.DocumentTypeFieldAssignment, 0, len(reqs))
|
||||
for _, a := range reqs {
|
||||
assignments = append(assignments, storage.DocumentTypeFieldAssignment{
|
||||
FieldID: a.FieldID, Required: a.Required, Visible: a.Visible, SortOrder: a.SortOrder,
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.store.SetDocumentTypeFields(r.Context(), docTypeID, *sess.TenantID, assignments); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, storage.ErrTaxonomyNotFound) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, storage.ErrCustomFieldNotFound) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "doc_type_fields_set doc_type:" + strconv.FormatInt(docTypeID, 10) + " err:" + err.Error()})
|
||||
writeError(w, status, "set document type fields failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "doc_type_fields_set doc_type:" + strconv.FormatInt(docTypeID, 10) + " count:" + strconv.Itoa(len(assignments)),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||
}
|
||||
|
||||
// --- document field values ---
|
||||
|
||||
// handleListDocumentFieldValues handles GET /api/documents/{id}/fields.
|
||||
func (s *Server) handleListDocumentFieldValues(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.GetDocument(r.Context(), docID, *sess.TenantID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
values, err := s.store.ListDocumentFieldValues(r.Context(), docID, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list document field values failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, values)
|
||||
}
|
||||
|
||||
// handleSetDocumentFieldValues handles PUT /api/documents/{id}/fields (batch).
|
||||
func (s *Server) handleSetDocumentFieldValues(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.GetDocument(r.Context(), docID, *sess.TenantID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
var inputs []storage.DocumentFieldValueInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&inputs); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body (expected array)")
|
||||
return
|
||||
}
|
||||
|
||||
changed, err := s.store.SetDocumentFieldValues(r.Context(), docID, *sess.TenantID, inputs)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
msg := "set document field values failed"
|
||||
if errors.Is(err, storage.ErrRequiredFieldMissing) {
|
||||
status = http.StatusBadRequest
|
||||
msg = err.Error()
|
||||
} else if errors.Is(err, storage.ErrCustomFieldNotFound) {
|
||||
status = http.StatusBadRequest
|
||||
msg = "unknown custom field"
|
||||
} else if strings.Contains(err.Error(), "invalid date") || strings.Contains(err.Error(), "not in enum options") {
|
||||
status = http.StatusBadRequest
|
||||
msg = err.Error()
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: strconv.FormatInt(docID, 10), Success: false, Detail: "custom_field_values_set err:" + err.Error(),
|
||||
})
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
for _, name := range changed {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: strconv.FormatInt(docID, 10), Success: true, Detail: "custom_field:" + name + " changed",
|
||||
})
|
||||
}
|
||||
// Re-sync the search index: custom-field values are (Phase 1) not yet a
|
||||
// dedicated indexed field, but the document projection is refreshed so the
|
||||
// index stays consistent and a later phase can start indexing field text
|
||||
// without a backfill gap. Best-effort, never fails the request.
|
||||
s.store.SyncIndex(r.Context(), docID)
|
||||
|
||||
values, err := s.store.ListDocumentFieldValues(r.Context(), docID, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "reload document field values failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, values)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
// handleDashboard returns aggregated, tenant-scoped key figures for the
|
||||
// dashboard (GET /api/dashboard). Any authenticated user may view their own
|
||||
// tenant's stats — no admin role required. Read-only, so no audit-log entry.
|
||||
// Superadmin accounts have no tenant_id (by design, see auth.Manager.issueToken);
|
||||
// they get an empty/zeroed snapshot instead of a 403, since there is no
|
||||
// single tenant to scope the query to.
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeJSON(w, http.StatusOK, &storage.DashboardStats{})
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := s.store.GetDashboardStats(r.Context(), *sess.TenantID, sess.UserID)
|
||||
if err != nil {
|
||||
s.logger.Error("dashboard stats failed", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "dashboard stats failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// extractDocumentDate scans OCR text for the most plausible document/invoice
|
||||
// date and returns it, or nil if none is found. It is a deliberately simple,
|
||||
// rule-based (no NLP, no external library, CGO-free) heuristic in the same
|
||||
// spirit as titleFromOCRText.
|
||||
//
|
||||
// Recognised formats:
|
||||
// - DD.MM.YYYY (German, e.g. 31.12.2024)
|
||||
// - DD.MM.YY (German short year, e.g. 31.12.24 -> 2024)
|
||||
// - DD/MM/YYYY (slash variant, incl. DD/MM/YY)
|
||||
// - YYYY-MM-DD (ISO 8601, e.g. 2024-12-31)
|
||||
// - "15. März 2026" / "15. Mär. 2026" (spelled-out German month names)
|
||||
//
|
||||
// Plausibility: month 1-12, day 1-31 with an explicit calendar check (time.Date
|
||||
// would normalise an impossible day, so we reject e.g. "31.02." rather than
|
||||
// silently shifting it to March), year not before dateMinYear, and never a date
|
||||
// in the future beyond today + dateFutureToleranceDays (a small tolerance for
|
||||
// timezone/clock skew). These filters drop copyright years, footer years and
|
||||
// stray digit runs.
|
||||
//
|
||||
// Scoring (see scoreForDatePosition): each candidate gets a confidence based on
|
||||
// signal words in a small text window around it ("Rechnungsdatum", "vom", ...).
|
||||
// The candidate with the highest score wins; on a tie the earliest occurrence in
|
||||
// the text wins (document head = usually the issue date). Callers that only need
|
||||
// the date use this function; callers that also need the confidence use
|
||||
// extractDocumentDateWithScore.
|
||||
const (
|
||||
dateMinYear = 1990
|
||||
dateFutureToleranceDays = 2
|
||||
dateWindowRadius = 40
|
||||
dateScoreNoContext = 0.4
|
||||
)
|
||||
|
||||
// dateKeyword pairs a lowercase signal word with the confidence a date near it
|
||||
// receives. Ordered by descending score: scoreForDatePosition returns the score
|
||||
// of the first (=highest) keyword found in the window. Kept short and flat on
|
||||
// purpose — no weighting engine, GoBD-traceable.
|
||||
type dateKeyword struct {
|
||||
word string
|
||||
score float64
|
||||
}
|
||||
|
||||
var dateKeywords = []dateKeyword{
|
||||
{"rechnungsdatum", 0.9},
|
||||
{"belegdatum", 0.9},
|
||||
{"ausstellungsdatum", 0.9},
|
||||
{"rechnung vom", 0.9},
|
||||
{"beleg vom", 0.9},
|
||||
{"datum", 0.75},
|
||||
{"vom", 0.55},
|
||||
}
|
||||
|
||||
// dateGermanMonths maps lowercased German month names and common abbreviations
|
||||
// (with the trailing dot already stripped) to their month number.
|
||||
var dateGermanMonths = map[string]int{
|
||||
"januar": 1, "jan": 1,
|
||||
"februar": 2, "feb": 2,
|
||||
"märz": 3, "maerz": 3, "mär": 3, "mrz": 3,
|
||||
"april": 4, "apr": 4,
|
||||
"mai": 5,
|
||||
"juni": 6, "jun": 6,
|
||||
"juli": 7, "jul": 7,
|
||||
"august": 8, "aug": 8,
|
||||
"september": 9, "sep": 9, "sept": 9,
|
||||
"oktober": 10, "okt": 10,
|
||||
"november": 11, "nov": 11,
|
||||
"dezember": 12, "dez": 12,
|
||||
}
|
||||
|
||||
// dateCandidateRe matches every supported format in a single alternation. Named
|
||||
// groups keep the branch handling readable. Word boundaries avoid gluing onto
|
||||
// surrounding digits (e.g. a phone number). Case-insensitive for month names.
|
||||
var dateCandidateRe = regexp.MustCompile(
|
||||
`(?i)(?:\b(?P<gd>\d{1,2})\.(?P<gm>\d{1,2})\.(?P<gy>\d{4}|\d{2})\b)` +
|
||||
`|(?:\b(?P<sd>\d{1,2})/(?P<sm>\d{1,2})/(?P<sy>\d{4}|\d{2})\b)` +
|
||||
`|(?:\b(?P<iy>\d{4})-(?P<im>\d{1,2})-(?P<id>\d{1,2})\b)` +
|
||||
`|(?:\b(?P<td>\d{1,2})\.?\s+(?P<tmon>[A-Za-zäöüÄÖÜ]+)\.?\s+(?P<ty>\d{4})\b)`,
|
||||
)
|
||||
|
||||
// sameDate reports whether two optional dates refer to the same calendar day
|
||||
// (or are both nil). Used by reprocess to skip a no-op document_date update.
|
||||
func sameDate(a, b *time.Time) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
ay, am, ad := a.Date()
|
||||
by, bm, bd := b.Date()
|
||||
return ay == by && am == bm && ad == bd
|
||||
}
|
||||
|
||||
// scoreForDatePosition returns the confidence for a date match found at byte
|
||||
// offset start in text, based on signal words within dateWindowRadius chars.
|
||||
func scoreForDatePosition(lowerText string, start int) float64 {
|
||||
lo := start - dateWindowRadius
|
||||
if lo < 0 {
|
||||
lo = 0
|
||||
}
|
||||
hi := start + dateWindowRadius
|
||||
if hi > len(lowerText) {
|
||||
hi = len(lowerText)
|
||||
}
|
||||
window := lowerText[lo:hi]
|
||||
for _, kw := range dateKeywords {
|
||||
if strings.Contains(window, kw.word) {
|
||||
return kw.score
|
||||
}
|
||||
}
|
||||
return dateScoreNoContext
|
||||
}
|
||||
|
||||
// parseDateMatch turns one regex submatch into a validated calendar date, or
|
||||
// returns ok=false if the match is implausible.
|
||||
func parseDateMatch(names, m []string) (time.Time, bool) {
|
||||
now := time.Now()
|
||||
maxDate := now.AddDate(0, 0, dateFutureToleranceDays)
|
||||
var day, month, year int
|
||||
var monthName string
|
||||
for i, name := range names {
|
||||
if m[i] == "" {
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case "gd", "sd", "id", "td":
|
||||
day, _ = strconv.Atoi(m[i])
|
||||
case "gm", "sm", "im":
|
||||
month, _ = strconv.Atoi(m[i])
|
||||
case "gy", "sy":
|
||||
y, _ := strconv.Atoi(m[i])
|
||||
if len(m[i]) == 2 {
|
||||
// Two-digit year: interpret as 2000-2099. Anything above the
|
||||
// future tolerance is rejected below.
|
||||
y += 2000
|
||||
}
|
||||
year = y
|
||||
case "iy", "ty":
|
||||
year, _ = strconv.Atoi(m[i])
|
||||
case "tmon":
|
||||
monthName = m[i]
|
||||
}
|
||||
}
|
||||
if monthName != "" {
|
||||
mn, ok := dateGermanMonths[strings.ToLower(monthName)]
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
month = mn
|
||||
}
|
||||
if year < dateMinYear {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if month < 1 || month > 12 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if day < 1 || day > 31 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
d := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||
// Reject normalised-away impossible days (e.g. 31.02. -> 03.03.).
|
||||
if d.Day() != day || int(d.Month()) != month || d.Year() != year {
|
||||
return time.Time{}, false
|
||||
}
|
||||
// No future dates beyond today + tolerance.
|
||||
if d.After(maxDate) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
|
||||
// extractDocumentDateWithScore returns the best belegdatum candidate and its
|
||||
// confidence. found=false when no plausible date exists in the text.
|
||||
func extractDocumentDateWithScore(ocrText string) (best time.Time, score float64, found bool) {
|
||||
if ocrText == "" {
|
||||
return time.Time{}, 0, false
|
||||
}
|
||||
lower := strings.ToLower(ocrText)
|
||||
idxMatches := dateCandidateRe.FindAllStringSubmatchIndex(ocrText, -1)
|
||||
names := dateCandidateRe.SubexpNames()
|
||||
for _, loc := range idxMatches {
|
||||
m := make([]string, len(names))
|
||||
for g := range names {
|
||||
s, e := loc[2*g], loc[2*g+1]
|
||||
if s >= 0 {
|
||||
m[g] = ocrText[s:e]
|
||||
}
|
||||
}
|
||||
d, ok := parseDateMatch(names, m)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sc := scoreForDatePosition(lower, loc[0])
|
||||
// Strictly greater keeps the earliest occurrence on a tie (matches are
|
||||
// returned in reading order).
|
||||
if !found || sc > score {
|
||||
best, score, found = d, sc, true
|
||||
}
|
||||
}
|
||||
return best, score, found
|
||||
}
|
||||
|
||||
// extractDocumentDate returns just the best belegdatum candidate (or nil),
|
||||
// preserving the original signature for callers that do not need the score.
|
||||
func extractDocumentDate(ocrText string) *time.Time {
|
||||
d, _, found := extractDocumentDateWithScore(ocrText)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
return &d
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/auth"
|
||||
"archivdms/internal/storage"
|
||||
"archivdms/internal/userstore"
|
||||
)
|
||||
|
||||
// maxBulkExportDocuments caps how many documents a single bulk export may
|
||||
// stream. Explicit ID lists beyond this are rejected with 400; a filter
|
||||
// selection resolving to more documents is likewise rejected so the caller
|
||||
// narrows the filter instead of silently receiving a truncated archive
|
||||
// (GoBD-Vollständigkeit: a partial export must never look complete).
|
||||
const maxBulkExportDocuments = 500
|
||||
|
||||
// bulkExportRequest is the POST /api/documents/export body. Either IDs or
|
||||
// Filter is used — IDs takes precedence when both are present.
|
||||
//
|
||||
// {"ids": [1,2,3]}
|
||||
// {"filter": {"doc_type_id": 4, "tag_ids": [7,9],
|
||||
// "document_date_from": "2026-01-01", "document_date_to": "2026-03-31"}}
|
||||
type bulkExportRequest struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
Filter *bulkExportFilter `json:"filter"`
|
||||
}
|
||||
|
||||
// bulkExportFilter mirrors the filter dimensions the list/search endpoints
|
||||
// already expose (doc type, correspondent, tags, time range). It is applied on
|
||||
// top of the tenant- and ACL-scoped result of Store.ListDocuments, so no new
|
||||
// SQL predicate — and no new place a tenant_id filter could be forgotten.
|
||||
type bulkExportFilter struct {
|
||||
DocTypeID *int64 `json:"doc_type_id"`
|
||||
CorrespondentID *int64 `json:"correspondent_id"`
|
||||
TagIDs []int64 `json:"tag_ids"`
|
||||
DocumentDateFrom string `json:"document_date_from"` // YYYY-MM-DD, inclusive
|
||||
DocumentDateTo string `json:"document_date_to"` // YYYY-MM-DD, inclusive
|
||||
UploadedFrom string `json:"uploaded_from"` // YYYY-MM-DD, inclusive
|
||||
UploadedTo string `json:"uploaded_to"` // YYYY-MM-DD, inclusive (whole day)
|
||||
}
|
||||
|
||||
// bulkExportCSVHeader is the index.csv header. Column names are deliberately
|
||||
// identical to the metadata.json field names (snake_case) so the later DATEV
|
||||
// formatter can map from one shared vocabulary.
|
||||
var bulkExportCSVHeader = []string{
|
||||
"document_id", "title", "doc_type", "correspondent",
|
||||
"document_date", "tags", "uploaded_at",
|
||||
}
|
||||
|
||||
// handleBulkExportDocuments streams a multi-document ZIP
|
||||
// (POST /api/documents/export):
|
||||
//
|
||||
// doc-<id>/<title>.<ext> original WORM file
|
||||
// doc-<id>/metadata.json same shape as the single-document export
|
||||
// doc-<id>/ocr_text.txt only when OCR text exists
|
||||
// index.csv one row per exported document
|
||||
// errors.txt only when documents were skipped
|
||||
//
|
||||
// ACL: identical to the single export — tenant scoping plus, for role 'user',
|
||||
// the per-document document_visibility check. Documents the caller may not see
|
||||
// (or that fail to read) are skipped and listed in errors.txt rather than
|
||||
// aborting the whole request.
|
||||
//
|
||||
// Audit: exactly ONE EventDocumentBulkExport entry per call, carrying the
|
||||
// exported/skipped counts.
|
||||
func (s *Server) handleBulkExportDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
tenantID := *sess.TenantID
|
||||
|
||||
fail := func(status int, msg, detail string) {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventDocumentBulkExport, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: false, Detail: detail,
|
||||
})
|
||||
writeError(w, status, msg)
|
||||
}
|
||||
|
||||
var req bulkExportRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
fail(http.StatusBadRequest, "invalid request body", "decode_failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 && req.Filter == nil {
|
||||
fail(http.StatusBadRequest, "ids oder filter erforderlich", "empty_selection")
|
||||
return
|
||||
}
|
||||
if len(req.IDs) > maxBulkExportDocuments {
|
||||
fail(http.StatusBadRequest,
|
||||
fmt.Sprintf("maximal %d Dokumente pro Export (angefragt: %d)", maxBulkExportDocuments, len(req.IDs)),
|
||||
fmt.Sprintf("too_many_ids: %d", len(req.IDs)))
|
||||
return
|
||||
}
|
||||
|
||||
// Role 'user' gets the group-resolved ACL applied by the store; domain
|
||||
// admins and superadmins see the whole tenant (roles are the outer boundary).
|
||||
var aclUserID *int64
|
||||
if !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
|
||||
uid := sess.UserID
|
||||
aclUserID = &uid
|
||||
}
|
||||
|
||||
// skipped collects IDs that were requested but not exported, with a reason.
|
||||
skipped := make([]string, 0, 8)
|
||||
|
||||
// Resolve the selection into a concrete, ordered document list.
|
||||
docs := make([]storage.Document, 0, len(req.IDs))
|
||||
if len(req.IDs) > 0 {
|
||||
for _, id := range req.IDs {
|
||||
doc, err := s.store.GetDocument(r.Context(), id, tenantID)
|
||||
if err != nil || doc == nil {
|
||||
skipped = append(skipped, fmt.Sprintf("%d: nicht gefunden", id))
|
||||
continue
|
||||
}
|
||||
if aclUserID != nil {
|
||||
visible, err := s.store.IsDocumentVisible(r.Context(), id, tenantID, *aclUserID)
|
||||
if err != nil {
|
||||
skipped = append(skipped, fmt.Sprintf("%d: Sichtbarkeitsprüfung fehlgeschlagen", id))
|
||||
continue
|
||||
}
|
||||
if !visible {
|
||||
skipped = append(skipped, fmt.Sprintf("%d: nicht sichtbar", id))
|
||||
continue
|
||||
}
|
||||
}
|
||||
docs = append(docs, *doc)
|
||||
}
|
||||
} else {
|
||||
all, err := s.store.ListDocuments(r.Context(), tenantID, aclUserID)
|
||||
if err != nil {
|
||||
fail(http.StatusInternalServerError, "export failed", "list_failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
filtered, err := s.filterBulkExportDocs(r, tenantID, all, req.Filter)
|
||||
if err != nil {
|
||||
fail(http.StatusBadRequest, err.Error(), "filter_invalid: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(filtered) > maxBulkExportDocuments {
|
||||
fail(http.StatusBadRequest,
|
||||
fmt.Sprintf("Filter trifft %d Dokumente, maximal %d pro Export — Filter eingrenzen", len(filtered), maxBulkExportDocuments),
|
||||
fmt.Sprintf("filter_too_broad: %d", len(filtered)))
|
||||
return
|
||||
}
|
||||
docs = filtered
|
||||
}
|
||||
|
||||
if len(docs) == 0 && len(skipped) == 0 {
|
||||
fail(http.StatusNotFound, "keine Dokumente für den Export gefunden", "empty_result")
|
||||
return
|
||||
}
|
||||
|
||||
ts := time.Now().UTC().Format("20060102-150405")
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"export-bulk-"+ts+".zip\"")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
writeEntry := func(name string, rd io.Reader) error {
|
||||
entry, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(entry, rd)
|
||||
return err
|
||||
}
|
||||
|
||||
csvBuf := &bytes.Buffer{}
|
||||
// UTF-8 BOM so Excel opens the CSV with correct umlauts.
|
||||
csvBuf.WriteString("\xef\xbb\xbf")
|
||||
cw := csv.NewWriter(csvBuf)
|
||||
cw.Comma = ';'
|
||||
_ = cw.Write(bulkExportCSVHeader)
|
||||
|
||||
exported := 0
|
||||
var streamErr error
|
||||
for i := range docs {
|
||||
doc := docs[i]
|
||||
row, err := s.writeBulkExportDoc(r, writeEntry, sess.Username, tenantID, &doc)
|
||||
if err != nil {
|
||||
// A single unreadable document must not kill the archive — unless the
|
||||
// ZIP writer itself failed, which we detect on Close below.
|
||||
s.logger.Warn("bulk export: document skipped", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
|
||||
skipped = append(skipped, fmt.Sprintf("%d: %v", doc.ID, err))
|
||||
continue
|
||||
}
|
||||
_ = cw.Write(row)
|
||||
exported++
|
||||
}
|
||||
cw.Flush()
|
||||
|
||||
if streamErr == nil {
|
||||
streamErr = writeEntry("index.csv", bytes.NewReader(csvBuf.Bytes()))
|
||||
}
|
||||
if streamErr == nil && len(skipped) > 0 {
|
||||
var b strings.Builder
|
||||
b.WriteString("Übersprungene Dokumente (nicht sichtbar, nicht gefunden oder Lesefehler):\n")
|
||||
for _, line := range skipped {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
streamErr = writeEntry("errors.txt", strings.NewReader(b.String()))
|
||||
}
|
||||
if closeErr := zw.Close(); streamErr == nil {
|
||||
streamErr = closeErr
|
||||
}
|
||||
|
||||
detail := fmt.Sprintf("zip_bulk_export: exported=%d skipped=%d", exported, len(skipped))
|
||||
if streamErr != nil {
|
||||
// Headers are already out — audit the partial export, no HTTP error.
|
||||
s.logger.Warn("bulk document export stream failed", "tenant_id", tenantID, "err", streamErr)
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventDocumentBulkExport, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: false, Detail: detail + " stream_failed: " + streamErr.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventDocumentBulkExport, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
// writeBulkExportDoc writes the doc-<id>/ folder of one document and returns
|
||||
// its index.csv row. Any error means "skip this document" — the caller keeps
|
||||
// the archive going and records the ID in errors.txt.
|
||||
func (s *Server) writeBulkExportDoc(
|
||||
r *http.Request,
|
||||
writeEntry func(string, io.Reader) error,
|
||||
username string,
|
||||
tenantID int64,
|
||||
doc *storage.Document,
|
||||
) ([]string, error) {
|
||||
ctx := r.Context()
|
||||
prefix := "doc-" + strconv.FormatInt(doc.ID, 10) + "/"
|
||||
|
||||
docTypeName, correspondentName, err := s.store.DocumentTaxonomyNames(ctx, doc.ID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Taxonomie nicht lesbar: %w", err)
|
||||
}
|
||||
// Bestandsschutz: fall back to the deprecated free-text columns.
|
||||
if docTypeName == "" {
|
||||
docTypeName = doc.DocType
|
||||
}
|
||||
if correspondentName == "" {
|
||||
correspondentName = doc.Correspondent
|
||||
}
|
||||
|
||||
tagEntities, err := s.store.ListDocumentTags(ctx, doc.ID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Tags nicht lesbar: %w", err)
|
||||
}
|
||||
tags := make([]string, 0, len(tagEntities))
|
||||
for _, t := range tagEntities {
|
||||
tags = append(tags, t.Name)
|
||||
}
|
||||
|
||||
fieldValues, err := s.store.ListDocumentFieldValues(ctx, doc.ID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Zusatzfelder nicht lesbar: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.Open(doc.StoragePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Datei nicht lesbar: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
meta := documentExportMetadata{
|
||||
DocumentID: doc.ID,
|
||||
TenantID: doc.TenantID,
|
||||
Title: doc.Title,
|
||||
DocType: docTypeName,
|
||||
Correspondent: correspondentName,
|
||||
Tags: tags,
|
||||
DocumentDateScore: doc.DocumentDateScore,
|
||||
UploadedAt: doc.CreatedAt,
|
||||
UpdatedAt: doc.UpdatedAt,
|
||||
CreatedBy: s.exportCreatorName(doc),
|
||||
ContentHash: doc.ContentHash,
|
||||
OriginalFilename: filepath.Base(doc.StoragePath),
|
||||
CustomFields: exportCustomFields(fieldValues),
|
||||
ExportedAt: time.Now().UTC(),
|
||||
ExportedBy: username,
|
||||
}
|
||||
docDate := ""
|
||||
if doc.DocumentDate != nil {
|
||||
docDate = doc.DocumentDate.Format("2006-01-02")
|
||||
meta.DocumentDate = &docDate
|
||||
}
|
||||
if doc.RetainUntil != nil {
|
||||
d := doc.RetainUntil.Format("2006-01-02")
|
||||
meta.RetainUntil = &d
|
||||
}
|
||||
metaJSON, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Metadaten nicht serialisierbar: %w", err)
|
||||
}
|
||||
|
||||
ext := filepath.Ext(doc.StoragePath)
|
||||
if err := writeEntry(prefix+safeDownloadName(doc.Title, ext), f); err != nil {
|
||||
return nil, fmt.Errorf("ZIP-Eintrag fehlgeschlagen: %w", err)
|
||||
}
|
||||
if err := writeEntry(prefix+"metadata.json", bytes.NewReader(metaJSON)); err != nil {
|
||||
return nil, fmt.Errorf("ZIP-Eintrag fehlgeschlagen: %w", err)
|
||||
}
|
||||
if doc.OCRText != "" {
|
||||
if err := writeEntry(prefix+"ocr_text.txt", strings.NewReader(doc.OCRText)); err != nil {
|
||||
return nil, fmt.Errorf("ZIP-Eintrag fehlgeschlagen: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return []string{
|
||||
strconv.FormatInt(doc.ID, 10),
|
||||
doc.Title,
|
||||
docTypeName,
|
||||
correspondentName,
|
||||
docDate,
|
||||
strings.Join(tags, ", "),
|
||||
doc.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// filterBulkExportDocs narrows an already tenant- and ACL-scoped document list
|
||||
// by the requested filter. Tag filtering needs a per-document lookup, so it is
|
||||
// applied last, after the cheap in-memory predicates.
|
||||
func (s *Server) filterBulkExportDocs(r *http.Request, tenantID int64, docs []storage.Document, f *bulkExportFilter) ([]storage.Document, error) {
|
||||
docFrom, err := parseBulkExportDate(f.DocumentDateFrom)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ungültiges document_date_from (erwartet YYYY-MM-DD)")
|
||||
}
|
||||
docTo, err := parseBulkExportDate(f.DocumentDateTo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ungültiges document_date_to (erwartet YYYY-MM-DD)")
|
||||
}
|
||||
upFrom, err := parseBulkExportDate(f.UploadedFrom)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ungültiges uploaded_from (erwartet YYYY-MM-DD)")
|
||||
}
|
||||
upTo, err := parseBulkExportDate(f.UploadedTo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ungültiges uploaded_to (erwartet YYYY-MM-DD)")
|
||||
}
|
||||
|
||||
out := make([]storage.Document, 0, len(docs))
|
||||
for i := range docs {
|
||||
d := docs[i]
|
||||
if f.DocTypeID != nil && (d.DocTypeID == nil || *d.DocTypeID != *f.DocTypeID) {
|
||||
continue
|
||||
}
|
||||
if f.CorrespondentID != nil && (d.CorrespondentID == nil || *d.CorrespondentID != *f.CorrespondentID) {
|
||||
continue
|
||||
}
|
||||
if docFrom != nil || docTo != nil {
|
||||
if d.DocumentDate == nil {
|
||||
continue
|
||||
}
|
||||
day := d.DocumentDate.UTC().Truncate(24 * time.Hour)
|
||||
if docFrom != nil && day.Before(*docFrom) {
|
||||
continue
|
||||
}
|
||||
if docTo != nil && day.After(*docTo) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if upFrom != nil && d.CreatedAt.UTC().Before(*upFrom) {
|
||||
continue
|
||||
}
|
||||
if upTo != nil && d.CreatedAt.UTC().After(upTo.Add(24*time.Hour-time.Nanosecond)) {
|
||||
continue
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
|
||||
if len(f.TagIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
want := make(map[int64]struct{}, len(f.TagIDs))
|
||||
for _, id := range f.TagIDs {
|
||||
want[id] = struct{}{}
|
||||
}
|
||||
tagged := make([]storage.Document, 0, len(out))
|
||||
for i := range out {
|
||||
tags, err := s.store.ListDocumentTags(r.Context(), out[i].ID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Tag-Filter fehlgeschlagen")
|
||||
}
|
||||
for _, t := range tags {
|
||||
if _, ok := want[t.ID]; ok {
|
||||
tagged = append(tagged, out[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return tagged, nil
|
||||
}
|
||||
|
||||
// parseBulkExportDate parses an optional YYYY-MM-DD filter bound (UTC).
|
||||
func parseBulkExportDate(s string) (*time.Time, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := time.ParseInLocation("2006-01-02", s, time.UTC)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse date %q: %w", s, err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/auth"
|
||||
"archivdms/internal/storage"
|
||||
"archivdms/internal/userstore"
|
||||
)
|
||||
|
||||
// documentExportMetadata is the metadata.json payload of a single-document
|
||||
// export. Field names are snake_case and mirror the API's document JSON so an
|
||||
// exported package stays readable/parsable without the API at hand (GoBD:
|
||||
// Verständlichkeit/Nachvollziehbarkeit of the exported archive package).
|
||||
type documentExportMetadata struct {
|
||||
DocumentID int64 `json:"document_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
Title string `json:"title"`
|
||||
DocType string `json:"doc_type"`
|
||||
Correspondent string `json:"correspondent"`
|
||||
Tags []string `json:"tags"`
|
||||
DocumentDate *string `json:"document_date"`
|
||||
DocumentDateScore *float64 `json:"document_date_score"`
|
||||
UploadedAt time.Time `json:"uploaded_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
OriginalFilename string `json:"original_filename"`
|
||||
RetainUntil *string `json:"retain_until"`
|
||||
CustomFields []exportedCustomField `json:"custom_fields"`
|
||||
ExportedAt time.Time `json:"exported_at"`
|
||||
ExportedBy string `json:"exported_by"`
|
||||
}
|
||||
|
||||
// exportedCustomField is one custom-field value in metadata.json. Exactly one
|
||||
// of the value pointers is populated, matching the field's type.
|
||||
type exportedCustomField struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
FieldType string `json:"field_type"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
ValueText *string `json:"value_text,omitempty"`
|
||||
ValueNumber *float64 `json:"value_number,omitempty"`
|
||||
ValueDate *string `json:"value_date,omitempty"`
|
||||
ValueBool *bool `json:"value_bool,omitempty"`
|
||||
}
|
||||
|
||||
// handleExportDocument streams a ZIP package for a single document
|
||||
// (GET /api/documents/{id}/export) containing:
|
||||
//
|
||||
// <title>.<ext> the original file, read from the WORM store via this handler
|
||||
// (never handing out storage_path itself)
|
||||
// metadata.json title, taxonomy, tags, belegdatum + score, timestamps,
|
||||
// creator and custom-field values
|
||||
// ocr_text.txt the OCR full text, only when the document has one
|
||||
//
|
||||
// ACL: tenant scoping via GetDocument (WHERE tenant_id) plus — for role 'user' —
|
||||
// the same document_visibility rule as the list endpoint (IsDocumentVisible).
|
||||
// domain_admin/superadmin skip the per-document check, roles being the outer
|
||||
// boundary, exactly like handleListDocuments.
|
||||
//
|
||||
// Unlike the plain download/preview endpoints this IS audit-logged (success and
|
||||
// failure): a complete metadata+content package leaving the system is treated
|
||||
// like a mutation for GoBD traceability, consistent with the compliance export
|
||||
// and the accounting pull API.
|
||||
func (s *Server) handleExportDocument(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil || sess.TenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
|
||||
fail := func(status int, msg, detail string) {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventDocumentExport, Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: idStr, Success: false, Detail: detail,
|
||||
})
|
||||
writeError(w, status, msg)
|
||||
}
|
||||
|
||||
doc, err := s.store.GetDocument(r.Context(), id, *sess.TenantID)
|
||||
if err != nil {
|
||||
fail(http.StatusNotFound, "document not found", "not_found")
|
||||
return
|
||||
}
|
||||
|
||||
// Group-resolved ACL for plain users; 404 (not 403) so the endpoint never
|
||||
// reveals the existence of a document the caller may not see.
|
||||
if !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
|
||||
visible, err := s.store.IsDocumentVisible(r.Context(), id, *sess.TenantID, sess.UserID)
|
||||
if err != nil {
|
||||
fail(http.StatusInternalServerError, "export failed", "visibility_check_failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !visible {
|
||||
fail(http.StatusNotFound, "document not found", "not_visible")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Gather metadata BEFORE any byte is written — once the ZIP stream has
|
||||
// started, the status code can no longer be changed.
|
||||
docTypeName, correspondentName, err := s.store.DocumentTaxonomyNames(r.Context(), id, *sess.TenantID)
|
||||
if err != nil {
|
||||
fail(http.StatusInternalServerError, "export failed", "taxonomy_lookup_failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
// Bestandsschutz: fall back to the deprecated free-text columns when no
|
||||
// structured entity is assigned.
|
||||
if docTypeName == "" {
|
||||
docTypeName = doc.DocType
|
||||
}
|
||||
if correspondentName == "" {
|
||||
correspondentName = doc.Correspondent
|
||||
}
|
||||
|
||||
tagEntities, err := s.store.ListDocumentTags(r.Context(), id, *sess.TenantID)
|
||||
if err != nil {
|
||||
fail(http.StatusInternalServerError, "export failed", "tag_lookup_failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
tags := make([]string, 0, len(tagEntities))
|
||||
for _, t := range tagEntities {
|
||||
tags = append(tags, t.Name)
|
||||
}
|
||||
|
||||
fieldValues, err := s.store.ListDocumentFieldValues(r.Context(), id, *sess.TenantID)
|
||||
if err != nil {
|
||||
fail(http.StatusInternalServerError, "export failed", "field_lookup_failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(doc.StoragePath)
|
||||
if err != nil {
|
||||
s.logger.Error("export: document file open failed", "document_id", doc.ID, "tenant_id", *sess.TenantID, "err", err)
|
||||
fail(http.StatusInternalServerError, "file unavailable", "file_open_failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
meta := documentExportMetadata{
|
||||
DocumentID: doc.ID,
|
||||
TenantID: doc.TenantID,
|
||||
Title: doc.Title,
|
||||
DocType: docTypeName,
|
||||
Correspondent: correspondentName,
|
||||
Tags: tags,
|
||||
DocumentDateScore: doc.DocumentDateScore,
|
||||
UploadedAt: doc.CreatedAt,
|
||||
UpdatedAt: doc.UpdatedAt,
|
||||
CreatedBy: s.exportCreatorName(doc),
|
||||
ContentHash: doc.ContentHash,
|
||||
OriginalFilename: filepath.Base(doc.StoragePath),
|
||||
CustomFields: exportCustomFields(fieldValues),
|
||||
ExportedAt: time.Now().UTC(),
|
||||
ExportedBy: sess.Username,
|
||||
}
|
||||
if doc.DocumentDate != nil {
|
||||
d := doc.DocumentDate.Format("2006-01-02")
|
||||
meta.DocumentDate = &d
|
||||
}
|
||||
if doc.RetainUntil != nil {
|
||||
d := doc.RetainUntil.Format("2006-01-02")
|
||||
meta.RetainUntil = &d
|
||||
}
|
||||
metaJSON, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
fail(http.StatusInternalServerError, "export failed", "metadata_marshal_failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ext := filepath.Ext(doc.StoragePath)
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"export-"+idStr+".zip\"")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
writeEntry := func(name string, r io.Reader) error {
|
||||
entry, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(entry, r)
|
||||
return err
|
||||
}
|
||||
|
||||
var streamErr error
|
||||
// 1. Original file (read through this handler, never exposing storage_path).
|
||||
if streamErr = writeEntry(safeDownloadName(doc.Title, ext), f); streamErr == nil {
|
||||
// 2. metadata.json
|
||||
streamErr = writeEntry("metadata.json", bytes.NewReader(metaJSON))
|
||||
}
|
||||
// 3. ocr_text.txt (only when OCR text exists)
|
||||
if streamErr == nil && doc.OCRText != "" {
|
||||
streamErr = writeEntry("ocr_text.txt", bytes.NewReader([]byte(doc.OCRText)))
|
||||
}
|
||||
if closeErr := zw.Close(); streamErr == nil {
|
||||
streamErr = closeErr
|
||||
}
|
||||
|
||||
if streamErr != nil {
|
||||
// Headers are already out — log + audit the partial export, no HTTP error.
|
||||
s.logger.Warn("document export stream failed", "document_id", doc.ID, "err", streamErr)
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventDocumentExport, Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: idStr, Success: false, Detail: "stream_failed: " + streamErr.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventDocumentExport, Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: idStr, Success: true, Detail: "zip_export",
|
||||
})
|
||||
}
|
||||
|
||||
// exportCreatorName resolves the uploading user's username for metadata.json.
|
||||
// Returns "" when the document has no created_by (e.g. SFTP watcher ingest) or
|
||||
// the user has since been deleted — the export must never fail over this.
|
||||
func (s *Server) exportCreatorName(doc *storage.Document) string {
|
||||
if doc.CreatedBy == nil || s.users == nil {
|
||||
return ""
|
||||
}
|
||||
u, err := s.users.GetByID(*doc.CreatedBy)
|
||||
if err != nil || u == nil {
|
||||
return ""
|
||||
}
|
||||
return u.Username
|
||||
}
|
||||
|
||||
// exportCustomFields maps stored custom-field values to their export shape,
|
||||
// normalising dates to ISO strings. Always a non-nil slice so metadata.json
|
||||
// carries [] rather than null.
|
||||
func exportCustomFields(values []storage.DocumentFieldValue) []exportedCustomField {
|
||||
out := make([]exportedCustomField, 0, len(values))
|
||||
for _, v := range values {
|
||||
e := exportedCustomField{
|
||||
Name: v.Name,
|
||||
Label: v.Label,
|
||||
FieldType: v.FieldType,
|
||||
Currency: v.Currency,
|
||||
ValueText: v.ValueText,
|
||||
ValueNumber: v.ValueNumber,
|
||||
ValueBool: v.ValueBool,
|
||||
}
|
||||
if v.ValueDate != nil {
|
||||
d := v.ValueDate.Format("2006-01-02")
|
||||
e.ValueDate = &d
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/auth"
|
||||
"archivdms/internal/storage"
|
||||
"archivdms/internal/userstore"
|
||||
)
|
||||
|
||||
// handleListDocumentNotes returns all free-text notes on a document
|
||||
// (GET /api/documents/{id}/notes). Tenant-scoped: ListDocumentNotes filters
|
||||
// WHERE tenant_id, so a foreign-tenant id simply yields an empty list. Pure
|
||||
// read — no audit entry, consistent with the other GET handlers.
|
||||
func (s *Server) handleListDocumentNotes(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || sess.TenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
notes, err := s.store.ListDocumentNotes(r.Context(), id, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list notes failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": notes})
|
||||
}
|
||||
|
||||
type createNoteRequest struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// handleCreateDocumentNote adds a free-text note to a document
|
||||
// (POST /api/documents/{id}/notes, body {"text": "..."}). The author is the
|
||||
// authenticated user. CreateDocumentNote verifies the document belongs to the
|
||||
// tenant before inserting (IDOR guard).
|
||||
func (s *Server) handleCreateDocumentNote(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || sess.TenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
var req createNoteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(req.Text)
|
||||
if text == "" {
|
||||
writeError(w, http.StatusBadRequest, "text is required")
|
||||
return
|
||||
}
|
||||
|
||||
note, err := s.store.CreateDocumentNote(r.Context(), id, *sess.TenantID, sess.UserID, text)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
msg := "create note failed"
|
||||
if errors.Is(err, storage.ErrDocumentNotFound) {
|
||||
status = http.StatusNotFound
|
||||
msg = "document not found"
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventNoteCreate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventNoteCreate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true, Detail: "note_id=" + strconv.FormatInt(note.ID, 10)})
|
||||
writeJSON(w, http.StatusCreated, note)
|
||||
}
|
||||
|
||||
// handleDeleteDocumentNote hard-deletes a note
|
||||
// (DELETE /api/documents/{id}/notes/{noteId}). Only the note's author or a
|
||||
// domain admin may delete it. 403 when not permitted, 404 when the note does
|
||||
// not exist for this document/tenant.
|
||||
func (s *Server) handleDeleteDocumentNote(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || sess.TenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
noteID, err := strconv.ParseInt(r.PathValue("noteId"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid note id")
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin := auth.HasRole(sess.Role, userstore.RoleDomainAdmin)
|
||||
if err := s.store.DeleteDocumentNote(r.Context(), noteID, id, *sess.TenantID, sess.UserID, isAdmin); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
msg := "delete note failed"
|
||||
if errors.Is(err, storage.ErrNoteForbidden) {
|
||||
status = http.StatusForbidden
|
||||
msg = "not allowed to delete this note"
|
||||
} else if errors.Is(err, storage.ErrNoteNotFound) {
|
||||
status = http.StatusNotFound
|
||||
msg = "note not found"
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventNoteDelete, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "note_id=" + r.PathValue("noteId") + ": " + err.Error()})
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventNoteDelete, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true, Detail: "note_id=" + r.PathValue("noteId")})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/ldapstore"
|
||||
"archivdms/internal/userstore"
|
||||
)
|
||||
|
||||
// resolveLDAPTenant determines which tenant's LDAP config the request targets.
|
||||
// domain_admin is pinned to its own session tenant. superadmin (which has no
|
||||
// session tenant) must pass ?tenant_id=; may also override to inspect any
|
||||
// tenant. Returns the tenant ID and false when the request is not authorised
|
||||
// or the tenant cannot be determined (the caller has already written nothing).
|
||||
func (s *Server) resolveLDAPTenant(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
|
||||
// superadmin may target any tenant via ?tenant_id=.
|
||||
if sess.Role == userstore.RoleSuperAdmin {
|
||||
q := r.URL.Query().Get("tenant_id")
|
||||
if q == "" {
|
||||
writeError(w, http.StatusBadRequest, "tenant_id query parameter required for superadmin")
|
||||
return 0, false
|
||||
}
|
||||
tid, err := strconv.ParseInt(q, 10, 64)
|
||||
if err != nil || tid <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid tenant_id")
|
||||
return 0, false
|
||||
}
|
||||
return tid, true
|
||||
}
|
||||
|
||||
// domain_admin: always scoped to its own tenant (IDOR-safe: query params
|
||||
// are ignored, the tenant comes from the signed session).
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "no tenant context")
|
||||
return 0, false
|
||||
}
|
||||
return *sess.TenantID, true
|
||||
}
|
||||
|
||||
// handleGetLDAPConfig returns the tenant's LDAP config WITHOUT the bind
|
||||
// password (only bind_password_set). Returns 404 when none is configured.
|
||||
func (s *Server) handleGetLDAPConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if s.ldapStore == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "ldap not configured on this server")
|
||||
return
|
||||
}
|
||||
tenantID, ok := s.resolveLDAPTenant(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := s.ldapStore.Get(r.Context(), tenantID)
|
||||
if errors.Is(err, ldapstore.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "no ldap config for this tenant")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "load ldap config failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
type upsertLDAPConfigRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
UseTLS string `json:"use_tls"`
|
||||
BindDN string `json:"bind_dn"`
|
||||
BindPassword *string `json:"bind_password"` // nil = keep existing
|
||||
BaseDN string `json:"base_dn"`
|
||||
UserFilter string `json:"user_filter"`
|
||||
AttrUsername string `json:"attr_username"`
|
||||
AttrEmail string `json:"attr_email"`
|
||||
AttrName string `json:"attr_name"`
|
||||
GroupBaseDN string `json:"group_base_dn"`
|
||||
GroupFilter string `json:"group_filter"`
|
||||
AdminGroupDN string `json:"admin_group_dn"`
|
||||
}
|
||||
|
||||
// handleUpsertLDAPConfig creates or updates the tenant's LDAP config. The bind
|
||||
// password is optional (omit to keep the stored one); on creation it is
|
||||
// mandatory. Every attempt — success or failure — is audit-logged.
|
||||
func (s *Server) handleUpsertLDAPConfig(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
|
||||
if s.ldapStore == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "ldap not configured on this server")
|
||||
return
|
||||
}
|
||||
tenantID, ok := s.resolveLDAPTenant(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
logFail := func(detail string) {
|
||||
tid := tenantID
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventLdapConfigChanged, Username: sess.Username,
|
||||
TenantID: &tid, Success: false, Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
var req upsertLDAPConfigRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
logFail("invalid_body")
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Host == "" || req.BindDN == "" || req.BaseDN == "" {
|
||||
logFail("missing_fields")
|
||||
writeError(w, http.StatusBadRequest, "host, bind_dn and base_dn are required")
|
||||
return
|
||||
}
|
||||
|
||||
// Defaults mirroring the schema so a minimal request still yields a
|
||||
// working config.
|
||||
if req.UserFilter == "" {
|
||||
req.UserFilter = "(uid=%s)"
|
||||
}
|
||||
if req.AttrUsername == "" {
|
||||
req.AttrUsername = "uid"
|
||||
}
|
||||
if req.AttrEmail == "" {
|
||||
req.AttrEmail = "mail"
|
||||
}
|
||||
if req.AttrName == "" {
|
||||
req.AttrName = "cn"
|
||||
}
|
||||
|
||||
cfg := ldapstore.Config{
|
||||
TenantID: tenantID,
|
||||
Enabled: req.Enabled,
|
||||
Host: req.Host,
|
||||
Port: req.Port,
|
||||
UseTLS: req.UseTLS,
|
||||
BindDN: req.BindDN,
|
||||
BaseDN: req.BaseDN,
|
||||
UserFilter: req.UserFilter,
|
||||
AttrUsername: req.AttrUsername,
|
||||
AttrEmail: req.AttrEmail,
|
||||
AttrName: req.AttrName,
|
||||
GroupBaseDN: req.GroupBaseDN,
|
||||
GroupFilter: req.GroupFilter,
|
||||
AdminGroupDN: req.AdminGroupDN,
|
||||
}
|
||||
|
||||
saved, err := s.ldapStore.Upsert(r.Context(), cfg, req.BindPassword)
|
||||
if err != nil {
|
||||
logFail("upsert_failed")
|
||||
writeError(w, http.StatusBadRequest, "save ldap config failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tid := tenantID
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventLdapConfigChanged, Username: sess.Username,
|
||||
TenantID: &tid, Success: true,
|
||||
Detail: "ldap_config_saved host:" + saved.Host,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, saved)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Heuristic metadata-suggestion HTTP handlers (see
|
||||
// internal/storage/metadata_suggestions.go):
|
||||
//
|
||||
// POST /api/documents/{id}/suggest-metadata
|
||||
// GET /api/documents/{id}/suggest-metadata
|
||||
// POST /api/documents/{id}/suggest-metadata/{suggestionId}/reviewed
|
||||
//
|
||||
// All three are normal authenticated tenant actions (s.auth). Suggestions are
|
||||
// rule-based (no LLM) and NON-binding: nothing here applies a suggested field —
|
||||
// accepting one goes through the normal edit endpoints (PATCH title,
|
||||
// tag-attach, ...). Ownership is enforced in the store layer (id+tenant_id).
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
// handleGenerateSuggestions handles POST /api/documents/{id}/suggest-metadata.
|
||||
// Triggers a fresh heuristic suggestion run and returns the persisted result.
|
||||
func (s *Server) handleGenerateSuggestions(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
docRef := strconv.FormatInt(docID, 10)
|
||||
|
||||
// provider selects the suggestion engine: "heuristic" (default, rule-based,
|
||||
// always available) or "ollama" (external LLM, only when the tenant has it
|
||||
// enabled). On an Ollama failure there is NO silent fallback to heuristic —
|
||||
// the error is surfaced so the frontend knows which provider did not answer.
|
||||
provider := r.URL.Query().Get("provider")
|
||||
if provider == "" {
|
||||
provider = "heuristic"
|
||||
}
|
||||
|
||||
var sug *storage.MetadataSuggestion
|
||||
switch provider {
|
||||
case "heuristic":
|
||||
sug, err = s.store.GenerateHeuristicSuggestions(r.Context(), docID, *sess.TenantID, &sess.UserID)
|
||||
case "ollama":
|
||||
cfg, cfgErr := s.store.GetOllamaConfig(r.Context(), *sess.TenantID)
|
||||
if cfgErr != nil {
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docRef, Success: false, Detail: "suggest_metadata ollama config err:" + cfgErr.Error()})
|
||||
writeError(w, http.StatusInternalServerError, "load ollama config failed")
|
||||
return
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docRef, Success: false, Detail: "suggest_metadata ollama not enabled"})
|
||||
writeError(w, http.StatusBadRequest, "ollama provider is not enabled for this tenant")
|
||||
return
|
||||
}
|
||||
sug, err = s.store.GenerateOllamaSuggestions(r.Context(), docID, *sess.TenantID, &sess.UserID, *cfg)
|
||||
case "naive_bayes":
|
||||
// Trained, dependency-free ML classifier (internal/classifier). Yields no
|
||||
// candidates for a kind whose model is untrained/below threshold — that is
|
||||
// not an error. Any real failure is surfaced (no silent fallback).
|
||||
sug, err = s.store.GenerateNaiveBayesSuggestions(r.Context(), docID, *sess.TenantID, &sess.UserID)
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "unknown provider (use 'heuristic', 'ollama' or 'naive_bayes')")
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, storage.ErrDocumentNotFound) {
|
||||
status = http.StatusNotFound
|
||||
} else if provider == "ollama" {
|
||||
// Ollama unreachable/timeout/bad-response: a dependency failure, not a
|
||||
// server bug. 502 signals "upstream provider failed".
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docRef, Success: false, Detail: "suggest_metadata provider:" + provider + " err:" + err.Error()})
|
||||
writeError(w, status, "generate metadata suggestions failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: docRef, Success: true, Detail: "suggest_metadata provider:" + provider + " id:" + strconv.FormatInt(sug.ID, 10),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, sug)
|
||||
}
|
||||
|
||||
// handleGetLatestSuggestion handles GET /api/documents/{id}/suggest-metadata.
|
||||
// Returns the most recent suggestion run for the document.
|
||||
func (s *Server) handleGetLatestSuggestion(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
sug, err := s.store.GetLatestSuggestion(r.Context(), docID, *sess.TenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrSuggestionNotFound) {
|
||||
writeError(w, http.StatusNotFound, "no metadata suggestion found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "get metadata suggestion failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sug)
|
||||
}
|
||||
|
||||
// handleMarkSuggestionReviewed handles
|
||||
// POST /api/documents/{id}/suggest-metadata/{suggestionId}/reviewed. Flags a
|
||||
// suggestion as reviewed (the user acted on it in the UI). Which fields were
|
||||
// accepted went through the normal edit endpoints, not this call.
|
||||
func (s *Server) handleMarkSuggestionReviewed(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
suggestionID, err := strconv.ParseInt(r.PathValue("suggestionId"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid suggestion id")
|
||||
return
|
||||
}
|
||||
docRef := strconv.FormatInt(docID, 10)
|
||||
if err := s.store.MarkSuggestionReviewed(r.Context(), suggestionID, *sess.TenantID, sess.UserID); err != nil {
|
||||
status := http.StatusNotFound
|
||||
if !errors.Is(err, storage.ErrSuggestionNotFound) {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docRef, Success: false, Detail: "suggest_metadata_reviewed id:" + strconv.FormatInt(suggestionID, 10) + " err:" + err.Error()})
|
||||
writeError(w, status, "mark suggestion reviewed failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: docRef, Success: true, Detail: "suggest_metadata_reviewed id:" + strconv.FormatInt(suggestionID, 10),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "reviewed"})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// ocrWordResponse is the wire shape of one OCR word box. It is a dedicated DTO
|
||||
// (not storage.OCRWord, which carries no json tags and exposes the internal row
|
||||
// id / document_id) so the overlay renderer gets a compact, stable payload.
|
||||
// Coordinates are in the original file's coordinate space — see
|
||||
// internal/ocr/coords.go.
|
||||
type ocrWordResponse struct {
|
||||
Text string `json:"text"`
|
||||
Left int `json:"left"`
|
||||
Top int `json:"top"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Page int `json:"page"`
|
||||
Block int `json:"block"`
|
||||
Par int `json:"par"`
|
||||
Line int `json:"line"`
|
||||
}
|
||||
|
||||
// handleListDocumentOCRWords returns the stored word-level bounding boxes of a
|
||||
// document (GET /api/documents/{id}/ocr-words), Phase 3 of the OCR
|
||||
// text-highlight/overlay feature.
|
||||
//
|
||||
// Tenant/ACL: ocr_words has no tenant_id column, access is only ever mediated
|
||||
// through document_id. The handler therefore performs the exact same ownership
|
||||
// check as handleDocumentAuditLog / handleGetDocumentFile — GetDocument(id,
|
||||
// tenantID) filters WHERE tenant_id and yields 404 for both "unknown id" and
|
||||
// "foreign tenant", so the endpoint never reveals whether a document exists
|
||||
// outside the caller's tenant — BEFORE any ocr_words row is read.
|
||||
//
|
||||
// Pure read: no audit entry, consistent with the other document GET handlers.
|
||||
// Empty result serializes as [] (never null).
|
||||
func (s *Server) handleListDocumentOCRWords(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || sess.TenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
// ACL/tenant check first — must precede the ocr_words lookup.
|
||||
if _, err := s.store.GetDocument(r.Context(), id, *sess.TenantID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
|
||||
words, err := s.store.ListOCRWords(r.Context(), id)
|
||||
if err != nil {
|
||||
s.logger.Error("list ocr words failed", "document_id", id, "tenant_id", *sess.TenantID, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "list ocr words failed")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]ocrWordResponse, 0, len(words))
|
||||
for _, wd := range words {
|
||||
out = append(out, ocrWordResponse{
|
||||
Text: wd.Word,
|
||||
Left: wd.Left,
|
||||
Top: wd.Top,
|
||||
Width: wd.Width,
|
||||
Height: wd.Height,
|
||||
Confidence: wd.Confidence,
|
||||
Page: wd.Page,
|
||||
Block: wd.Block,
|
||||
Par: wd.Par,
|
||||
Line: wd.Line,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Per-tenant configuration for an EXTERNAL, already-running Ollama server
|
||||
// (never installed on the archivdms host — the base URL/port comes from the
|
||||
// tenant admin). Gates the optional 'ollama' metadata-suggestion provider.
|
||||
//
|
||||
// GET /api/ollama-config
|
||||
// PUT /api/ollama-config
|
||||
//
|
||||
// Both are admin-only (domain_admin manages its own tenant; superadmin must
|
||||
// pass ?tenant_id=), mirroring the LDAP-config and tenant-settings handlers.
|
||||
// The base URL is an internal network URL, not a secret, and is returned as-is.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/llm"
|
||||
)
|
||||
|
||||
// handleGetOllamaConfig returns the tenant's Ollama connection config. A tenant
|
||||
// that has never configured Ollama gets a zero/default (disabled) config, not a
|
||||
// 404 — the frontend always renders an editable form.
|
||||
func (s *Server) handleGetOllamaConfig(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, ok := s.resolveTenantSettingsTenant(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cfg, err := s.store.GetOllamaConfig(r.Context(), tenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "load ollama config failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
// handleListOllamaModels queries an Ollama server for the models actually
|
||||
// installed there, so the frontend can offer a picklist instead of a free-text
|
||||
// field. Live call, no caching. Prefers the not-yet-saved ?base_url= query
|
||||
// param (lets the admin test a URL before hitting "Speichern"); falls back to
|
||||
// the persisted config's base_url when the param is absent. 400 if neither is
|
||||
// set. When the external Ollama server is unreachable the failure is the
|
||||
// external dependency's, not ours → 502 Bad Gateway, not 500.
|
||||
func (s *Server) handleListOllamaModels(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, ok := s.resolveTenantSettingsTenant(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := s.store.GetOllamaConfig(r.Context(), tenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "load ollama config failed")
|
||||
return
|
||||
}
|
||||
baseURL := r.URL.Query().Get("base_url")
|
||||
if baseURL == "" {
|
||||
baseURL = cfg.BaseURL
|
||||
}
|
||||
if baseURL == "" {
|
||||
writeError(w, http.StatusBadRequest, "Server-URL muss zuerst eingetragen werden")
|
||||
return
|
||||
}
|
||||
|
||||
// Short, listing-specific timeout — independent of the (possibly long)
|
||||
// generate timeout. Cap the stored value so a large generate timeout does
|
||||
// not make the picklist request hang for minutes.
|
||||
timeout := 10 * time.Second
|
||||
if cfg.TimeoutSeconds > 0 && cfg.TimeoutSeconds < 10 {
|
||||
timeout = time.Duration(cfg.TimeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
models, err := llm.ListModels(r.Context(), baseURL, timeout)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "ollama nicht erreichbar: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string][]string{"models": models})
|
||||
}
|
||||
|
||||
type upsertOllamaConfigRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
}
|
||||
|
||||
// handleUpsertOllamaConfig creates or updates the tenant's Ollama connection
|
||||
// config. Validation (enabled requires base_url+model, http(s) prefix, timeout
|
||||
// range) lives in the store. Every attempt — success or failure — is
|
||||
// audit-logged (GoBD).
|
||||
func (s *Server) handleUpsertOllamaConfig(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
|
||||
tenantID, ok := s.resolveTenantSettingsTenant(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
logFail := func(detail string) {
|
||||
tid := tenantID
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventOllamaConfigUpdate, Username: sess.Username,
|
||||
TenantID: &tid, Success: false, Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
var req upsertOllamaConfigRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
logFail("ollama_config invalid_body")
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.UpsertOllamaConfig(r.Context(), tenantID, req.Enabled, req.BaseURL, req.Model, req.TimeoutSeconds); err != nil {
|
||||
logFail("ollama_config upsert_failed:" + err.Error())
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tid := tenantID
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventOllamaConfigUpdate, Username: sess.Username,
|
||||
TenantID: &tid, Success: true, Detail: "ollama_config_saved",
|
||||
})
|
||||
|
||||
// Reload so the response reflects the persisted (normalised) state.
|
||||
cfg, err := s.store.GetOllamaConfig(r.Context(), tenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "load ollama config failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
// Permission-model HTTP handlers (see internal/storage/permissions.go):
|
||||
//
|
||||
// POST/GET/DELETE /api/permission-groups
|
||||
// GET/POST/DELETE /api/permission-groups/{id}/members
|
||||
// GET/POST/DELETE /api/document-types/{id}/grants
|
||||
// GET/POST/DELETE /api/tags/{id}/grants
|
||||
// GET/POST/DELETE /api/documents/{id}/grants (access may be 'deny')
|
||||
//
|
||||
// Group and grant administration require domain_admin (s.authAdmin). Ownership
|
||||
// is enforced in the store layer (id + tenant_id). Every mutation is
|
||||
// audit-logged (EventPermissionGrantChanged), including failures.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
// logGrant records a permission mutation, success or failure.
|
||||
func (s *Server) logGrant(r *http.Request, tenantID *int64, username, detail string, ok bool) {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventPermissionGrantChanged, Username: username, TenantID: tenantID,
|
||||
IPAddress: s.remoteIP(r), Success: ok, Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
// grantStatus maps store errors to an HTTP status.
|
||||
func grantStatus(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrPermissionGroupNotFound):
|
||||
return http.StatusNotFound
|
||||
case errors.Is(err, storage.ErrGrantNotFound):
|
||||
return http.StatusNotFound
|
||||
case errors.Is(err, storage.ErrDuplicatePermissionGroup):
|
||||
return http.StatusConflict
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
// --- permission groups ---
|
||||
|
||||
type permissionGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// handleListPermissionGroups handles GET /api/permission-groups.
|
||||
func (s *Server) handleListPermissionGroups(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
groups, err := s.store.ListPermissionGroups(r.Context(), *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list permission groups failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, groups)
|
||||
}
|
||||
|
||||
// handleCreatePermissionGroup handles POST /api/permission-groups (domain_admin+).
|
||||
func (s *Server) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
var req permissionGroupRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
g, err := s.store.CreatePermissionGroup(r.Context(), *sess.TenantID, req.Name)
|
||||
if err != nil {
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "group_create name:"+req.Name+" err:"+err.Error(), false)
|
||||
writeError(w, grantStatus(err), "create permission group failed")
|
||||
return
|
||||
}
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "group_create id:"+strconv.FormatInt(g.ID, 10)+" name:"+g.Name, true)
|
||||
writeJSON(w, http.StatusCreated, g)
|
||||
}
|
||||
|
||||
// handleDeletePermissionGroup handles DELETE /api/permission-groups/{id} (domain_admin+).
|
||||
func (s *Server) handleDeletePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeletePermissionGroup(r.Context(), id, *sess.TenantID); err != nil {
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "group_delete id:"+strconv.FormatInt(id, 10)+" err:"+err.Error(), false)
|
||||
writeError(w, grantStatus(err), "delete permission group failed")
|
||||
return
|
||||
}
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "group_delete id:"+strconv.FormatInt(id, 10), true)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// --- group membership ---
|
||||
|
||||
type groupMemberRequest struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
|
||||
// handleAddGroupMember handles POST /api/permission-groups/{id}/members (domain_admin+).
|
||||
func (s *Server) handleAddGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid group id")
|
||||
return
|
||||
}
|
||||
var req groupMemberRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
writeError(w, http.StatusBadRequest, "user_id is required")
|
||||
return
|
||||
}
|
||||
if err := s.store.AddGroupMember(r.Context(), groupID, req.UserID, *sess.TenantID); err != nil {
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "member_add group:"+strconv.FormatInt(groupID, 10)+" user:"+strconv.FormatInt(req.UserID, 10)+" err:"+err.Error(), false)
|
||||
writeError(w, grantStatus(err), "add group member failed")
|
||||
return
|
||||
}
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "member_add group:"+strconv.FormatInt(groupID, 10)+" user:"+strconv.FormatInt(req.UserID, 10), true)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "added"})
|
||||
}
|
||||
|
||||
// handleRemoveGroupMember handles DELETE /api/permission-groups/{id}/members/{userId} (domain_admin+).
|
||||
func (s *Server) handleRemoveGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid group id")
|
||||
return
|
||||
}
|
||||
userID, err := strconv.ParseInt(r.PathValue("userId"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
if err := s.store.RemoveGroupMember(r.Context(), groupID, userID, *sess.TenantID); err != nil {
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "member_remove group:"+strconv.FormatInt(groupID, 10)+" user:"+strconv.FormatInt(userID, 10)+" err:"+err.Error(), false)
|
||||
writeError(w, grantStatus(err), "remove group member failed")
|
||||
return
|
||||
}
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "member_remove group:"+strconv.FormatInt(groupID, 10)+" user:"+strconv.FormatInt(userID, 10), true)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
|
||||
}
|
||||
|
||||
// handleListGroupMembers handles GET /api/permission-groups/{id}/members (domain_admin+).
|
||||
func (s *Server) handleListGroupMembers(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid group id")
|
||||
return
|
||||
}
|
||||
members, err := s.store.ListGroupMembersDetailed(r.Context(), groupID, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, grantStatus(err), "list group members failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, members)
|
||||
}
|
||||
|
||||
// --- grants (document-type / tag / document) ---
|
||||
|
||||
type grantRequest struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
Access string `json:"access"`
|
||||
}
|
||||
|
||||
// handleSetDocumentTypeGrant handles POST /api/document-types/{id}/grants (domain_admin+).
|
||||
func (s *Server) handleSetDocumentTypeGrant(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docTypeID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document type id")
|
||||
return
|
||||
}
|
||||
var req grantRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Access == "" {
|
||||
req.Access = "read"
|
||||
}
|
||||
if err := s.store.SetDocumentTypeGrant(r.Context(), *sess.TenantID, docTypeID, req.GroupID, req.Access); err != nil {
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "type_grant_set type:"+strconv.FormatInt(docTypeID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" err:"+err.Error(), false)
|
||||
writeError(w, grantStatusValidated(err), "set document type grant failed")
|
||||
return
|
||||
}
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "type_grant_set type:"+strconv.FormatInt(docTypeID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" access:"+req.Access, true)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "granted"})
|
||||
}
|
||||
|
||||
// handleListDocumentTypeGrants handles GET /api/document-types/{id}/grants (domain_admin+).
|
||||
func (s *Server) handleListDocumentTypeGrants(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docTypeID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document type id")
|
||||
return
|
||||
}
|
||||
grants, err := s.store.ListDocumentTypeGrants(r.Context(), *sess.TenantID, docTypeID)
|
||||
if err != nil {
|
||||
writeError(w, grantStatus(err), "list document type grants failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, grants)
|
||||
}
|
||||
|
||||
// handleDeleteDocumentTypeGrant handles DELETE /api/document-types/{id}/grants (domain_admin+).
|
||||
func (s *Server) handleDeleteDocumentTypeGrant(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docTypeID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document type id")
|
||||
return
|
||||
}
|
||||
groupID, err := grantGroupID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "group_id is required")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteDocumentTypeGrant(r.Context(), *sess.TenantID, docTypeID, groupID); err != nil {
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "type_grant_delete type:"+strconv.FormatInt(docTypeID, 10)+" group:"+strconv.FormatInt(groupID, 10)+" err:"+err.Error(), false)
|
||||
writeError(w, grantStatus(err), "delete document type grant failed")
|
||||
return
|
||||
}
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "type_grant_delete type:"+strconv.FormatInt(docTypeID, 10)+" group:"+strconv.FormatInt(groupID, 10), true)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||
}
|
||||
|
||||
// handleSetTagGrant handles POST /api/tags/{id}/grants (domain_admin+).
|
||||
func (s *Server) handleSetTagGrant(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
tagID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid tag id")
|
||||
return
|
||||
}
|
||||
var req grantRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Access == "" {
|
||||
req.Access = "read"
|
||||
}
|
||||
if err := s.store.SetTagGrant(r.Context(), *sess.TenantID, tagID, req.GroupID, req.Access); err != nil {
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "tag_grant_set tag:"+strconv.FormatInt(tagID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" err:"+err.Error(), false)
|
||||
writeError(w, grantStatusValidated(err), "set tag grant failed")
|
||||
return
|
||||
}
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "tag_grant_set tag:"+strconv.FormatInt(tagID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" access:"+req.Access, true)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "granted"})
|
||||
}
|
||||
|
||||
// handleListTagGrants handles GET /api/tags/{id}/grants (domain_admin+).
|
||||
func (s *Server) handleListTagGrants(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
tagID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid tag id")
|
||||
return
|
||||
}
|
||||
grants, err := s.store.ListTagGrants(r.Context(), *sess.TenantID, tagID)
|
||||
if err != nil {
|
||||
writeError(w, grantStatus(err), "list tag grants failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, grants)
|
||||
}
|
||||
|
||||
// handleDeleteTagGrant handles DELETE /api/tags/{id}/grants (domain_admin+).
|
||||
func (s *Server) handleDeleteTagGrant(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
tagID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid tag id")
|
||||
return
|
||||
}
|
||||
groupID, err := grantGroupID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "group_id is required")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteTagGrant(r.Context(), *sess.TenantID, tagID, groupID); err != nil {
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "tag_grant_delete tag:"+strconv.FormatInt(tagID, 10)+" group:"+strconv.FormatInt(groupID, 10)+" err:"+err.Error(), false)
|
||||
writeError(w, grantStatus(err), "delete tag grant failed")
|
||||
return
|
||||
}
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "tag_grant_delete tag:"+strconv.FormatInt(tagID, 10)+" group:"+strconv.FormatInt(groupID, 10), true)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||
}
|
||||
|
||||
// handleSetDocumentGrant handles POST /api/documents/{id}/grants (domain_admin+).
|
||||
// access may be 'read', 'write' or 'deny'.
|
||||
func (s *Server) handleSetDocumentGrant(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
var req grantRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Access == "" {
|
||||
writeError(w, http.StatusBadRequest, "access is required (read|write|deny)")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetDocumentGrant(r.Context(), *sess.TenantID, docID, req.GroupID, sess.UserID, req.Access); err != nil {
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "doc_grant_set doc:"+strconv.FormatInt(docID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" err:"+err.Error(), false)
|
||||
writeError(w, grantStatusValidated(err), "set document grant failed")
|
||||
return
|
||||
}
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "doc_grant_set doc:"+strconv.FormatInt(docID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" access:"+req.Access, true)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "granted"})
|
||||
}
|
||||
|
||||
// handleListDocumentGrants handles GET /api/documents/{id}/grants (domain_admin+).
|
||||
// access may be 'read', 'write' or 'deny'.
|
||||
func (s *Server) handleListDocumentGrants(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
grants, err := s.store.ListDocumentGrants(r.Context(), *sess.TenantID, docID)
|
||||
if err != nil {
|
||||
writeError(w, grantStatus(err), "list document grants failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, grants)
|
||||
}
|
||||
|
||||
// handleDeleteDocumentGrant handles DELETE /api/documents/{id}/grants (domain_admin+).
|
||||
func (s *Server) handleDeleteDocumentGrant(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
groupID, err := grantGroupID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "group_id is required")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteDocumentGrant(r.Context(), *sess.TenantID, docID, groupID); err != nil {
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "doc_grant_delete doc:"+strconv.FormatInt(docID, 10)+" group:"+strconv.FormatInt(groupID, 10)+" err:"+err.Error(), false)
|
||||
writeError(w, grantStatus(err), "delete document grant failed")
|
||||
return
|
||||
}
|
||||
s.logGrant(r, sess.TenantID, sess.Username, "doc_grant_delete doc:"+strconv.FormatInt(docID, 10)+" group:"+strconv.FormatInt(groupID, 10), true)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||
}
|
||||
|
||||
// grantGroupID resolves the target group for a grant DELETE, from either the
|
||||
// ?group_id query param or a JSON body {"group_id": N}.
|
||||
func grantGroupID(r *http.Request) (int64, error) {
|
||||
if q := r.URL.Query().Get("group_id"); q != "" {
|
||||
return strconv.ParseInt(q, 10, 64)
|
||||
}
|
||||
var req grantRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if req.GroupID == 0 {
|
||||
return 0, errors.New("group_id is required")
|
||||
}
|
||||
return req.GroupID, nil
|
||||
}
|
||||
|
||||
// grantStatusValidated maps an "invalid access" validation error to 400,
|
||||
// otherwise defers to grantStatus.
|
||||
func grantStatusValidated(err error) int {
|
||||
if err == nil {
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
if strings.Contains(err.Error(), "invalid access") {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
return grantStatus(err)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
// processingJobResponse ist die schlanke Sicht auf einen Queue-Job, die das
|
||||
// Frontend für Statusbadge und Retry-Button braucht. Bewusst nicht der volle
|
||||
// storage.ProcessingJob: interne Felder (derive_title, next_attempt_at,
|
||||
// tenant_id) haben in der UI nichts zu suchen.
|
||||
type processingJobResponse struct {
|
||||
DocumentID int64 `json:"document_id"`
|
||||
Status string `json:"status"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
// handleGetProcessingJob liefert den Verarbeitungsstatus eines Dokuments
|
||||
// (GET /api/documents/{id}/processing-job).
|
||||
//
|
||||
// ACL wie bei allen Dokument-Sub-Routen: erst GetDocument(id, tenantID) — der
|
||||
// tenant_id-Filter dort ist der IDOR-Guard, ein fremdes Dokument liefert 404
|
||||
// noch bevor irgendein Job gelesen wird.
|
||||
//
|
||||
// Hat ein Dokument keinen Job (kompletter Altbestand vor Einführung der
|
||||
// Queue), wird KEIN 404 geliefert, sondern der processing_status des
|
||||
// Dokuments selbst (Spalten-Default 'done'). Damit muss das Frontend keinen
|
||||
// Sonderfall kennen: es bekommt immer einen Status.
|
||||
func (s *Server) handleGetProcessingJob(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || sess.TenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
tenantID := *sess.TenantID
|
||||
|
||||
doc, err := s.store.GetDocument(r.Context(), id, tenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrDocumentNotFound) {
|
||||
writeError(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "load document failed")
|
||||
return
|
||||
}
|
||||
|
||||
job, err := s.store.GetJobForDocument(r.Context(), id, tenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrNoJob) {
|
||||
status := doc.ProcessingStatus
|
||||
if status == "" {
|
||||
status = storage.JobStatusDone
|
||||
}
|
||||
writeJSON(w, http.StatusOK, processingJobResponse{DocumentID: id, Status: status})
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "load processing job failed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, processingJobResponse{
|
||||
DocumentID: id,
|
||||
Status: job.Status,
|
||||
RetryCount: job.RetryCount,
|
||||
ErrorMessage: job.ErrorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
// handleRetryProcessingJob stellt einen dauerhaft fehlgeschlagenen Job manuell
|
||||
// zurück in die Queue (POST /api/documents/{id}/processing-job/retry).
|
||||
//
|
||||
// Nur aus dem Status 'failed' heraus erlaubt — ein laufender oder bereits
|
||||
// fertiger Job darf nicht zurückgesetzt werden (409), sonst könnte ein Klick
|
||||
// eine gerade laufende Verarbeitung doppelt anstoßen. Der eigentliche Retry
|
||||
// läuft danach ganz normal über den Dispatcher.
|
||||
func (s *Server) handleRetryProcessingJob(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || sess.TenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
tenantID := *sess.TenantID
|
||||
|
||||
if _, err := s.store.GetDocument(r.Context(), id, tenantID); err != nil {
|
||||
if errors.Is(err, storage.ErrDocumentNotFound) {
|
||||
writeError(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "load document failed")
|
||||
return
|
||||
}
|
||||
|
||||
job, err := s.store.GetJobForDocument(r.Context(), id, tenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrNoJob) {
|
||||
writeError(w, http.StatusNotFound, "no processing job for document")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "load processing job failed")
|
||||
return
|
||||
}
|
||||
if job.Status != storage.JobStatusFailed {
|
||||
writeError(w, http.StatusConflict, "processing job is not in failed state")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.RequeueJob(r.Context(), job.ID, tenantID); err != nil {
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentProcessed, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "manual retry failed: " + err.Error()})
|
||||
writeError(w, http.StatusInternalServerError, "requeue failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentProcessed, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true, Detail: "manual retry requeued job_id=" + strconv.FormatInt(job.ID, 10)})
|
||||
|
||||
writeJSON(w, http.StatusOK, processingJobResponse{DocumentID: id, Status: storage.JobStatusQueued})
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// Public (unauthenticated) share-link handlers. These are wired into the mux
|
||||
// WITHOUT the s.auth middleware (see server.go): the share token itself is the
|
||||
// only credential. Every lookup goes through the SHA-256 token_hash, never an
|
||||
// id; every attempt is rate-limited per client IP and recorded in
|
||||
// document_share_accesses (and, for downloads, the audit log). The archived
|
||||
// file is streamed straight from the WORM store — storage_path/content_hash are
|
||||
// never exposed to the client.
|
||||
//
|
||||
// GET /public/share/{token} metadata (title, whether a password is needed)
|
||||
// POST /public/share/{token}/download body optional {password}; streams the file
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
// publicShareMeta is the safe, minimal public view of a share.
|
||||
type publicShareMeta struct {
|
||||
Title string `json:"title"`
|
||||
RequiresPassword bool `json:"requires_password"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// publicDownloadRequest is the optional JSON body for the download endpoint.
|
||||
type publicDownloadRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// handlePublicShareMeta handles GET /public/share/{token}. It reveals only the
|
||||
// document title, whether a password is required, and the expiry — never the
|
||||
// file. Revoked/expired/max-reached shares are reported as such but never leak
|
||||
// the title.
|
||||
func (s *Server) handlePublicShareMeta(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.remoteIP(r)
|
||||
if !s.shareLimiter.allow(ip) {
|
||||
writeError(w, http.StatusTooManyRequests, "too many requests")
|
||||
return
|
||||
}
|
||||
token := r.PathValue("token")
|
||||
rs, err := s.store.ResolveShareByToken(r.Context(), token)
|
||||
if err != nil {
|
||||
// Unknown token: indistinguishable 404, nothing to log (no share_id).
|
||||
writeError(w, http.StatusNotFound, "share not found")
|
||||
return
|
||||
}
|
||||
if _, stateErr := rs.VerifyState(time.Now()); stateErr != nil {
|
||||
writeError(w, shareStateStatus(stateErr), shareStateMessage(stateErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, publicShareMeta{
|
||||
Title: rs.DocumentTitle,
|
||||
RequiresPassword: rs.HasPassword(),
|
||||
ExpiresAt: rs.ExpiresAt(),
|
||||
})
|
||||
}
|
||||
|
||||
// handlePublicShareDownload handles POST /public/share/{token}/download. Check
|
||||
// order: rate-limit -> resolve -> revoked -> expired -> max_accesses ->
|
||||
// password -> deliver (atomic access_count++). Every branch records an access
|
||||
// row and the terminal outcome is audit-logged (EventShareAccessed).
|
||||
func (s *Server) handlePublicShareDownload(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.remoteIP(r)
|
||||
token := r.PathValue("token")
|
||||
|
||||
rs, err := s.store.ResolveShareByToken(r.Context(), token)
|
||||
if err != nil {
|
||||
// Unknown token: 404, no share to attach an access row to.
|
||||
writeError(w, http.StatusNotFound, "share not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limit now that we have a share_id to log a 'rate_limited' attempt.
|
||||
if !s.shareLimiter.allow(ip) {
|
||||
s.recordShareAccess(r, rs, ip, storage.ShareResultRateLimited)
|
||||
writeError(w, http.StatusTooManyRequests, "too many requests")
|
||||
return
|
||||
}
|
||||
|
||||
if result, stateErr := rs.VerifyState(time.Now()); stateErr != nil {
|
||||
s.recordShareAccess(r, rs, ip, result)
|
||||
writeError(w, shareStateStatus(stateErr), shareStateMessage(stateErr))
|
||||
return
|
||||
}
|
||||
|
||||
var body publicDownloadRequest
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&body) // body is optional
|
||||
}
|
||||
if err := rs.VerifyPassword(body.Password); err != nil {
|
||||
s.recordShareAccess(r, rs, ip, storage.ShareResultBadPassword)
|
||||
writeError(w, http.StatusUnauthorized, "password required or incorrect")
|
||||
return
|
||||
}
|
||||
|
||||
// Atomically claim one access slot (closes the max_accesses race).
|
||||
ok, err := s.store.IncrementShareAccess(r.Context(), rs.ShareID())
|
||||
if err != nil {
|
||||
s.logger.Error("share access increment failed", "share_id", rs.ShareID(), "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "download failed")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
// Lost the race (revoked/expired/max between our check and the update).
|
||||
s.recordShareAccess(r, rs, ip, storage.ShareResultMaxReached)
|
||||
writeError(w, http.StatusForbidden, "share no longer available")
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(rs.StoragePath())
|
||||
if err != nil {
|
||||
s.logger.Error("share file open failed", "share_id", rs.ShareID(), "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "download failed")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
s.recordShareAccess(r, rs, ip, storage.ShareResultSuccess)
|
||||
|
||||
ext := filepath.Ext(rs.StoragePath())
|
||||
w.Header().Set("Content-Type", detectMimeType("", ext, rs.StoragePath()))
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+safeDownloadName(rs.DocumentTitle, ext)+"\"")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if _, err := io.Copy(w, f); err != nil {
|
||||
s.logger.Warn("share file stream interrupted", "share_id", rs.ShareID(), "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// recordShareAccess writes the per-attempt access row and mirrors the outcome
|
||||
// into the audit log (EventShareAccessed). Never blocks the response path.
|
||||
func (s *Server) recordShareAccess(r *http.Request, rs *storage.ResolvedShare, ip, result string) {
|
||||
if err := s.store.LogShareAccess(r.Context(), rs.ShareID(), ip, r.UserAgent(), result); err != nil {
|
||||
s.logger.Error("share access log failed", "share_id", rs.ShareID(), "err", err)
|
||||
}
|
||||
tenantID := rs.TenantID()
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventShareAccessed,
|
||||
Username: "public",
|
||||
IPAddress: ip,
|
||||
TenantID: &tenantID,
|
||||
DocumentID: strconv.FormatInt(rs.DocumentID(), 10),
|
||||
Success: result == storage.ShareResultSuccess,
|
||||
Detail: "share:" + strconv.FormatInt(rs.ShareID(), 10) + " result:" + result,
|
||||
})
|
||||
}
|
||||
|
||||
// shareStateStatus maps a share-state error to an HTTP status.
|
||||
func shareStateStatus(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrShareRevoked):
|
||||
return http.StatusForbidden
|
||||
case errors.Is(err, storage.ErrShareExpired):
|
||||
return http.StatusGone
|
||||
case errors.Is(err, storage.ErrShareMaxReached):
|
||||
return http.StatusForbidden
|
||||
default:
|
||||
return http.StatusForbidden
|
||||
}
|
||||
}
|
||||
|
||||
func shareStateMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrShareRevoked):
|
||||
return "share revoked"
|
||||
case errors.Is(err, storage.ErrShareExpired):
|
||||
return "share expired"
|
||||
case errors.Is(err, storage.ErrShareMaxReached):
|
||||
return "share access limit reached"
|
||||
default:
|
||||
return "share not available"
|
||||
}
|
||||
}
|
||||
|
||||
// safeDownloadName builds a Content-Disposition filename from the document
|
||||
// title, stripping anything that could break the header or the client's
|
||||
// filesystem, and appending the stored extension.
|
||||
func safeDownloadName(title, ext string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
title = "document"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, ch := range title {
|
||||
switch {
|
||||
case ch >= 'a' && ch <= 'z', ch >= 'A' && ch <= 'Z', ch >= '0' && ch <= '9':
|
||||
b.WriteRune(ch)
|
||||
case ch == '-', ch == '_', ch == '.', ch == ' ':
|
||||
b.WriteRune(ch)
|
||||
default:
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
name := strings.TrimSpace(b.String())
|
||||
if name == "" {
|
||||
name = "document"
|
||||
}
|
||||
if ext != "" && !strings.HasSuffix(strings.ToLower(name), strings.ToLower(ext)) {
|
||||
name += ext
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// --- per-IP token-bucket rate limiter ---
|
||||
|
||||
// ipRateLimiter is a minimal in-memory per-IP token-bucket limiter (no external
|
||||
// dependency). Each IP gets its own bucket of `burst` tokens, refilled at
|
||||
// `refillPerSec` tokens per second. Buckets are created lazily and swept when
|
||||
// they have been idle and full for a while.
|
||||
type ipRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*tokenBucket
|
||||
burst float64
|
||||
refillPerSec float64
|
||||
lastSweep time.Time
|
||||
}
|
||||
|
||||
type tokenBucket struct {
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func newIPRateLimiter(burst, refillPerSec float64) *ipRateLimiter {
|
||||
return &ipRateLimiter{
|
||||
buckets: make(map[string]*tokenBucket),
|
||||
burst: burst,
|
||||
refillPerSec: refillPerSec,
|
||||
lastSweep: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// allow consumes one token for ip, returning false when the bucket is empty.
|
||||
func (l *ipRateLimiter) allow(ip string) bool {
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.sweepLocked(now)
|
||||
|
||||
b, ok := l.buckets[ip]
|
||||
if !ok {
|
||||
b = &tokenBucket{tokens: l.burst, last: now}
|
||||
l.buckets[ip] = b
|
||||
}
|
||||
// Refill based on elapsed time.
|
||||
elapsed := now.Sub(b.last).Seconds()
|
||||
b.tokens += elapsed * l.refillPerSec
|
||||
if b.tokens > l.burst {
|
||||
b.tokens = l.burst
|
||||
}
|
||||
b.last = now
|
||||
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
// sweepLocked drops idle, full buckets roughly once a minute to bound memory.
|
||||
func (l *ipRateLimiter) sweepLocked(now time.Time) {
|
||||
if now.Sub(l.lastSweep) < time.Minute {
|
||||
return
|
||||
}
|
||||
l.lastSweep = now
|
||||
for ip, b := range l.buckets {
|
||||
if now.Sub(b.last) > 10*time.Minute {
|
||||
delete(l.buckets, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Wiedervorlage (reminder) HTTP handlers:
|
||||
// POST /api/documents/{id}/reminders
|
||||
// GET /api/reminders?status=
|
||||
// PATCH /api/reminders/{id}
|
||||
// DELETE /api/reminders/{id}
|
||||
//
|
||||
// All routes require s.auth(...) (authenticated + tenant context). Ownership
|
||||
// is enforced in the store layer (id+tenant_id+user_id). Every mutation is
|
||||
// audit-logged, including failures.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
type createReminderRequest struct {
|
||||
DueDate string `json:"due_date"` // RFC3339
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// handleCreateReminder handles POST /api/documents/{id}/reminders.
|
||||
func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
|
||||
var req createReminderRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
dueDate, err := time.Parse(time.RFC3339, req.DueDate)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "due_date must be RFC3339")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the document exists and belongs to the caller's tenant before
|
||||
// attaching a reminder to it (the FK alone would only stop a fully
|
||||
// nonexistent document_id, not a cross-tenant one).
|
||||
if _, err := s.store.GetDocument(r.Context(), docID, *sess.TenantID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
|
||||
rem, err := s.store.CreateReminder(r.Context(), docID, *sess.TenantID, sess.UserID, dueDate, req.Note)
|
||||
if err != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventReminderCreate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: strconv.FormatInt(docID, 10), Success: false, Detail: err.Error(),
|
||||
})
|
||||
writeError(w, http.StatusInternalServerError, "create reminder failed")
|
||||
return
|
||||
}
|
||||
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventReminderCreate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: strconv.FormatInt(docID, 10), Success: true,
|
||||
Detail: "reminder_id:" + strconv.FormatInt(rem.ID, 10),
|
||||
})
|
||||
writeJSON(w, http.StatusCreated, rem)
|
||||
}
|
||||
|
||||
// handleListReminders handles GET /api/reminders?status=.
|
||||
func (s *Server) handleListReminders(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
status := r.URL.Query().Get("status")
|
||||
if status != "" && status != storage.ReminderStatusOpen && status != storage.ReminderStatusDone && status != storage.ReminderStatusDismissed {
|
||||
writeError(w, http.StatusBadRequest, "invalid status filter")
|
||||
return
|
||||
}
|
||||
|
||||
reminders, err := s.store.ListReminders(r.Context(), *sess.TenantID, sess.UserID, status)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list reminders failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, reminders)
|
||||
}
|
||||
|
||||
type updateReminderRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// handleUpdateReminder handles PATCH /api/reminders/{id}.
|
||||
func (s *Server) handleUpdateReminder(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid reminder id")
|
||||
return
|
||||
}
|
||||
var req updateReminderRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
switch req.Status {
|
||||
case storage.ReminderStatusOpen, storage.ReminderStatusDone, storage.ReminderStatusDismissed:
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "invalid status")
|
||||
return
|
||||
}
|
||||
|
||||
rem, err := s.store.UpdateReminderStatus(r.Context(), id, *sess.TenantID, sess.UserID, req.Status)
|
||||
if err != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventReminderStatusChange, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Detail: "reminder_id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(), Success: false,
|
||||
})
|
||||
writeError(w, http.StatusNotFound, "reminder not found")
|
||||
return
|
||||
}
|
||||
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventReminderStatusChange, Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: strconv.FormatInt(rem.DocumentID, 10), Success: true,
|
||||
Detail: "reminder_id:" + strconv.FormatInt(rem.ID, 10) + " status:" + rem.Status,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, rem)
|
||||
}
|
||||
|
||||
// handleDeleteReminder handles DELETE /api/reminders/{id}.
|
||||
func (s *Server) handleDeleteReminder(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid reminder id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.DeleteReminder(r.Context(), id, *sess.TenantID, sess.UserID); err != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventReminderDelete, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Detail: "reminder_id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(), Success: false,
|
||||
})
|
||||
writeError(w, http.StatusNotFound, "reminder not found")
|
||||
return
|
||||
}
|
||||
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventReminderDelete, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Detail: "reminder_id:" + strconv.FormatInt(id, 10), Success: true,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// GoBD retention-rule ("Aufbewahrungsregeln") HTTP handlers (see
|
||||
// internal/storage/retention_rules.go):
|
||||
//
|
||||
// GET /api/retention-rules list all rules of the tenant
|
||||
// POST /api/retention-rules create a rule
|
||||
// PATCH /api/retention-rules/{id} update a rule
|
||||
// DELETE /api/retention-rules/{id} delete a rule
|
||||
// GET /api/retention-rules/eligible documents eligible for disposition
|
||||
// GET /api/retention-rules/preview dry-run of ApplyRetentionRules (no write)
|
||||
//
|
||||
// Rules are compliance-critical (they define how long documents must be kept),
|
||||
// so create/update/delete require domain_admin (s.authAdmin). Reading (list,
|
||||
// eligible, preview) is a normal tenant action (s.auth). Ownership is enforced
|
||||
// in the store layer (id+tenant_id). Every mutation is audit-logged, including
|
||||
// failures.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
// retentionRuleRequest is the JSON body for create/update. Pointers where the
|
||||
// store field is a pointer, so "not set" round-trips correctly.
|
||||
type retentionRuleRequest struct {
|
||||
DocTypeID *int64 `json:"doc_type_id"`
|
||||
Name string `json:"name"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
TriggerReference string `json:"trigger_reference"`
|
||||
RetentionYears *int `json:"retention_years"`
|
||||
RetentionDays *int `json:"retention_days"`
|
||||
LegalBasis string `json:"legal_basis"`
|
||||
RequiresApprovalForDestroy *bool `json:"requires_approval_for_destroy"`
|
||||
DSGVOConflict *bool `json:"dsgvo_conflict"`
|
||||
Active *bool `json:"active"`
|
||||
}
|
||||
|
||||
// toRule maps the request onto a storage.RetentionRule. requires_approval and
|
||||
// active default to true when omitted (safe GoBD default: keep approval on).
|
||||
func (req retentionRuleRequest) toRule() storage.RetentionRule {
|
||||
requiresApproval := true
|
||||
if req.RequiresApprovalForDestroy != nil {
|
||||
requiresApproval = *req.RequiresApprovalForDestroy
|
||||
}
|
||||
active := true
|
||||
if req.Active != nil {
|
||||
active = *req.Active
|
||||
}
|
||||
dsgvo := false
|
||||
if req.DSGVOConflict != nil {
|
||||
dsgvo = *req.DSGVOConflict
|
||||
}
|
||||
return storage.RetentionRule{
|
||||
DocTypeID: req.DocTypeID,
|
||||
Name: req.Name,
|
||||
TriggerType: req.TriggerType,
|
||||
TriggerReference: req.TriggerReference,
|
||||
RetentionYears: req.RetentionYears,
|
||||
RetentionDays: req.RetentionDays,
|
||||
LegalBasis: req.LegalBasis,
|
||||
RequiresApprovalForDestroy: requiresApproval,
|
||||
DSGVOConflict: dsgvo,
|
||||
Active: active,
|
||||
}
|
||||
}
|
||||
|
||||
// handleListRetentionRules handles GET /api/retention-rules.
|
||||
func (s *Server) handleListRetentionRules(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
rules, err := s.store.ListRetentionRules(r.Context(), *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list retention rules failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rules)
|
||||
}
|
||||
|
||||
// handleCreateRetentionRule handles POST /api/retention-rules (domain_admin+).
|
||||
func (s *Server) handleCreateRetentionRule(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
var req retentionRuleRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
rule := req.toRule()
|
||||
rule.CreatedBy = &sess.UserID
|
||||
|
||||
created, err := s.store.CreateRetentionRule(r.Context(), *sess.TenantID, rule)
|
||||
if err != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventRetentionRuleCreate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: false, Detail: "retention_rule_create err:" + err.Error(),
|
||||
})
|
||||
writeError(w, retentionRuleErrStatus(err), retentionRuleErrMsg(err, "create retention rule failed"))
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventRetentionRuleCreate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "retention_rule_create id:" + strconv.FormatInt(created.ID, 10) + " name:" + created.Name,
|
||||
})
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// handleUpdateRetentionRule handles PATCH /api/retention-rules/{id} (domain_admin+).
|
||||
func (s *Server) handleUpdateRetentionRule(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var req retentionRuleRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
updated, err := s.store.UpdateRetentionRule(r.Context(), id, *sess.TenantID, req.toRule())
|
||||
if err != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventRetentionRuleUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: false, Detail: "retention_rule_update id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(),
|
||||
})
|
||||
writeError(w, retentionRuleErrStatus(err), retentionRuleErrMsg(err, "update retention rule failed"))
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventRetentionRuleUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "retention_rule_update id:" + strconv.FormatInt(id, 10),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// handleDeleteRetentionRule handles DELETE /api/retention-rules/{id} (domain_admin+).
|
||||
func (s *Server) handleDeleteRetentionRule(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteRetentionRule(r.Context(), id, *sess.TenantID); err != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventRetentionRuleDelete, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: false, Detail: "retention_rule_delete id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(),
|
||||
})
|
||||
writeError(w, retentionRuleErrStatus(err), retentionRuleErrMsg(err, "delete retention rule failed"))
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventRetentionRuleDelete, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "retention_rule_delete id:" + strconv.FormatInt(id, 10),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// handleListEligibleForDisposition handles GET /api/retention-rules/eligible:
|
||||
// documents whose retention has expired but which are not yet in the trash.
|
||||
func (s *Server) handleListEligibleForDisposition(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docs, err := s.store.ListEligibleForDisposition(r.Context(), *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list eligible-for-disposition documents failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, docs)
|
||||
}
|
||||
|
||||
// handlePreviewRetentionRules handles GET /api/retention-rules/preview: a
|
||||
// dry-run of ApplyRetentionRules for the current tenant. No writes.
|
||||
func (s *Server) handlePreviewRetentionRules(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
preview, err := s.store.PreviewRetentionRules(r.Context(), *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "preview retention rules failed")
|
||||
return
|
||||
}
|
||||
if preview == nil {
|
||||
preview = []storage.RetentionPreview{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, preview)
|
||||
}
|
||||
|
||||
// retentionRuleErrStatus maps store errors to HTTP status codes. Validation
|
||||
// errors (bad trigger_type, missing retention period, bad fixed_date) surface as
|
||||
// 400; not-found as 404; everything else 500.
|
||||
func retentionRuleErrStatus(err error) int {
|
||||
if errors.Is(err, storage.ErrRetentionRuleNotFound) {
|
||||
return http.StatusNotFound
|
||||
}
|
||||
if isRetentionValidationErr(err) {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// retentionRuleErrMsg returns the validation message verbatim (safe, no PII) so
|
||||
// the frontend can show it, or a generic fallback otherwise.
|
||||
func retentionRuleErrMsg(err error, fallback string) string {
|
||||
if errors.Is(err, storage.ErrRetentionRuleNotFound) {
|
||||
return "retention rule not found"
|
||||
}
|
||||
if isRetentionValidationErr(err) {
|
||||
return err.Error()
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// isRetentionValidationErr reports whether err is a validateRetentionRule
|
||||
// cross-field error (all prefixed "retention rule:" in the store).
|
||||
func isRetentionValidationErr(err error) bool {
|
||||
if err == nil || errors.Is(err, storage.ErrRetentionRuleNotFound) {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
const prefix = "retention rule: "
|
||||
return len(msg) >= len(prefix) && msg[:len(prefix)] == prefix
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
// handleListSavedViews serves GET /api/saved-views — the caller's own saved
|
||||
// search views plus every view shared tenant-wide (is_shared). Pure read, not
|
||||
// audited (consistent with the rest of the project).
|
||||
func (s *Server) handleListSavedViews(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
views, err := s.store.ListSavedViews(r.Context(), *sess.TenantID, sess.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list saved views failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"views": views})
|
||||
}
|
||||
|
||||
// savedViewRequest is the create/update body. Filters carries the serialized
|
||||
// search query verbatim (see index.SearchQuery / handleSearchDocuments); it is
|
||||
// stored as-is in the saved_views.filters JSONB column so the client can
|
||||
// re-hydrate it 1:1 into a new search request.
|
||||
type savedViewRequest struct {
|
||||
Name string `json:"name"`
|
||||
Filters json.RawMessage `json:"filters"`
|
||||
IsShared bool `json:"is_shared"`
|
||||
}
|
||||
|
||||
// handleCreateSavedView serves POST /api/saved-views.
|
||||
func (s *Server) handleCreateSavedView(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
var req savedViewRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
// Default an empty/omitted filters payload to an empty JSON object so the
|
||||
// NOT NULL JSONB column always receives valid JSON.
|
||||
if len(req.Filters) == 0 {
|
||||
req.Filters = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
view, err := s.store.CreateSavedView(r.Context(), *sess.TenantID, sess.UserID, name, req.Filters, req.IsShared)
|
||||
if err != nil {
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventSavedViewCreate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: err.Error()})
|
||||
writeError(w, http.StatusInternalServerError, "create saved view failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventSavedViewCreate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "saved_view_id=" + strconv.FormatInt(view.ID, 10),
|
||||
})
|
||||
writeJSON(w, http.StatusCreated, view)
|
||||
}
|
||||
|
||||
// handleUpdateSavedView serves PATCH /api/saved-views/{id}. Only the view's
|
||||
// creator may update it (403 otherwise).
|
||||
func (s *Server) handleUpdateSavedView(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || sess.TenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid saved view id")
|
||||
return
|
||||
}
|
||||
var req savedViewRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
if len(req.Filters) == 0 {
|
||||
req.Filters = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
if err := s.store.UpdateSavedView(r.Context(), id, *sess.TenantID, sess.UserID, name, req.Filters, req.IsShared); err != nil {
|
||||
status, msg := savedViewErrStatus(err, "update saved view failed")
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventSavedViewUpdate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventSavedViewUpdate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||
}
|
||||
|
||||
// handleDeleteSavedView serves DELETE /api/saved-views/{id}. Only the view's
|
||||
// creator may delete it (403 otherwise).
|
||||
func (s *Server) handleDeleteSavedView(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || sess.TenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid saved view id")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteSavedView(r.Context(), id, *sess.TenantID, sess.UserID); err != nil {
|
||||
status, msg := savedViewErrStatus(err, "delete saved view failed")
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventSavedViewDelete, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventSavedViewDelete, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// savedViewErrStatus maps store errors to an HTTP status + message: not-found
|
||||
// -> 404, forbidden (exists but owned by another user) -> 403, else 500.
|
||||
func savedViewErrStatus(err error, defaultMsg string) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrSavedViewNotFound):
|
||||
return http.StatusNotFound, "saved view not found"
|
||||
case errors.Is(err, storage.ErrSavedViewForbidden):
|
||||
return http.StatusForbidden, "not allowed to modify this saved view"
|
||||
default:
|
||||
return http.StatusInternalServerError, defaultMsg
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"archivdms/internal/auth"
|
||||
"archivdms/internal/index"
|
||||
"archivdms/internal/storage"
|
||||
"archivdms/internal/userstore"
|
||||
)
|
||||
|
||||
// handleSearchDocuments serves GET /api/documents/search — full-text +
|
||||
// attribute search backed by the per-tenant Manticore index.
|
||||
//
|
||||
// q full-text term (matched against title/ocr_text/tags/
|
||||
// correspondent/doc_type). Optional; when empty the query
|
||||
// degrades to a filter-only listing ordered by recency.
|
||||
// tag repeatable tag id filter (documents carrying ANY given tag).
|
||||
// doc_type_id restrict to a single document type.
|
||||
// page 1-based page number (default 1).
|
||||
// page_size hits per page (default 20, capped at 100).
|
||||
//
|
||||
// ACL: role 'user' is filtered against their permission-group memberships
|
||||
// (ANY(acl_group_ids)); domain_admin/superadmin bypass the ACL, exactly like
|
||||
// handleListDocuments. When the search backend is not configured the endpoint
|
||||
// returns 503 rather than a silent empty result.
|
||||
func (s *Server) handleSearchDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
tenantID := *sess.TenantID
|
||||
|
||||
q := index.SearchQuery{
|
||||
Query: strings.TrimSpace(r.URL.Query().Get("q")),
|
||||
Page: parsePositiveInt(r.URL.Query().Get("page"), 1),
|
||||
PageSize: clampInt(parsePositiveInt(r.URL.Query().Get("page_size"), 20), 1, 100),
|
||||
TagIDs: parseInt64List(r.URL.Query()["tag"]),
|
||||
DocTypeID: parseOptionalInt64(r.URL.Query().Get("doc_type_id")),
|
||||
}
|
||||
|
||||
// Roles are the outer ACL boundary (see handleListDocuments): role 'user'
|
||||
// is filtered against document_visibility via their group memberships;
|
||||
// domain_admin/superadmin see every document in the tenant (ACLGroupIDs nil).
|
||||
if !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
|
||||
groupIDs, err := s.store.ListGroupIDsForUser(r.Context(), sess.UserID, tenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "search failed")
|
||||
return
|
||||
}
|
||||
// Non-nil (possibly empty) slice => ACL filter applies. An empty slice
|
||||
// means the user is in no group and therefore sees nothing.
|
||||
q.ACLGroupIDs = groupIDs
|
||||
}
|
||||
|
||||
res, err := s.store.SearchDocuments(r.Context(), tenantID, q)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrSearchUnavailable) {
|
||||
writeError(w, http.StatusServiceUnavailable, "Suche nicht verfügbar, Manticore nicht konfiguriert")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "search failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// --- small query-param parsing helpers ---
|
||||
|
||||
func parsePositiveInt(s string, def int) int {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n <= 0 {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func clampInt(n, min, max int) int {
|
||||
if n < min {
|
||||
return min
|
||||
}
|
||||
if n > max {
|
||||
return max
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func parseOptionalInt64(s string) *int64 {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &n
|
||||
}
|
||||
|
||||
// parseInt64List parses a slice of query values (each possibly comma-separated)
|
||||
// into positive int64 ids, silently dropping anything unparseable.
|
||||
func parseInt64List(vals []string) []int64 {
|
||||
var out []int64
|
||||
for _, v := range vals {
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.ParseInt(part, 10, 64); err == nil && n > 0 {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
// Package api is the archivdms HTTP API server, ported from archivmail's
|
||||
// internal/api/server.go pattern: net/http ServeMux, an s.auth/s.authAdmin
|
||||
// middleware chain, JWT session extraction, and application-level
|
||||
// tenant-context propagation (tenantFromCtx). No mail-specific routes.
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"archivdms/config"
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/auth"
|
||||
"archivdms/internal/ldapauth"
|
||||
"archivdms/internal/ldapstore"
|
||||
"archivdms/internal/mailer"
|
||||
"archivdms/internal/ocr"
|
||||
"archivdms/internal/pagesplit"
|
||||
"archivdms/internal/storage"
|
||||
"archivdms/internal/tenantstore"
|
||||
"archivdms/internal/thumbnail"
|
||||
"archivdms/internal/userstore"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
sessionKey contextKey = "session"
|
||||
tenantKey contextKey = "tenant_id"
|
||||
)
|
||||
|
||||
// Server is the archivdms HTTP API server.
|
||||
type Server struct {
|
||||
cfg config.APIConfig
|
||||
storageCfg config.StorageConfig
|
||||
startTime time.Time
|
||||
store *storage.Store
|
||||
authMgr *auth.Manager
|
||||
users *userstore.Store
|
||||
audlog *audit.Logger
|
||||
logger *slog.Logger
|
||||
mux *http.ServeMux
|
||||
ocr *ocr.Extractor
|
||||
thumbs *thumbnail.Generator
|
||||
// pagesplitter performs barcode separator-page splitting of multi-page PDF
|
||||
// uploads before archival (internal/pagesplit). May be nil / disabled, in
|
||||
// which case every upload is archived as a single document as before.
|
||||
pagesplitter *pagesplit.Detector
|
||||
|
||||
tenantStore *tenantstore.Store
|
||||
mailer *mailer.Mailer
|
||||
fqdn string
|
||||
appVersion string
|
||||
|
||||
// ldapStore/ldapAuth are wired via SetLDAP. Both may be nil when LDAP is
|
||||
// unconfigured — the config endpoints then return 503.
|
||||
ldapStore *ldapstore.Store
|
||||
ldapAuth *ldapauth.Authenticator
|
||||
|
||||
// shareLimiter rate-limits the unauthenticated public share endpoints
|
||||
// (per client IP) to blunt token/password enumeration.
|
||||
shareLimiter *ipRateLimiter
|
||||
|
||||
// accountingLimiter rate-limits the Bearer-key Buchhaltungs-Pull-API
|
||||
// (per client IP) to blunt API-key guessing. Separate bucket set from
|
||||
// shareLimiter so a busy accounting client cannot starve share downloads.
|
||||
accountingLimiter *ipRateLimiter
|
||||
}
|
||||
|
||||
// SetStorageConfig wires the storage configuration (inbox/store/ocr-tmp
|
||||
// paths, max upload size) into the API server. Needed by
|
||||
// handleUploadDocument, which cannot rely solely on the storage.Store
|
||||
// (that only knows its own base dir, not the inbox/ocr-tmp layout).
|
||||
func (s *Server) SetStorageConfig(cfg config.StorageConfig) {
|
||||
s.storageCfg = cfg
|
||||
}
|
||||
|
||||
// SetOCR wires the OCR extractor into the API server. May be nil, in which
|
||||
// case uploads succeed with an empty ocr_text and an audit warning.
|
||||
func (s *Server) SetOCR(e *ocr.Extractor) {
|
||||
s.ocr = e
|
||||
}
|
||||
|
||||
// SetThumbnailer wires the preview-thumbnail generator. May be nil, in which
|
||||
// case the thumbnail endpoint returns 404 and the UI falls back to a generic
|
||||
// file icon.
|
||||
func (s *Server) SetThumbnailer(g *thumbnail.Generator) {
|
||||
s.thumbs = g
|
||||
}
|
||||
|
||||
// SetPageSplitter wires the barcode separator-page detector used at ingest.
|
||||
// May be nil or disabled (config.PageSplitConfig.Enabled=false, the default),
|
||||
// in which case multi-page uploads are archived unsplit as before.
|
||||
func (s *Server) SetPageSplitter(d *pagesplit.Detector) {
|
||||
s.pagesplitter = d
|
||||
}
|
||||
|
||||
// SetTenants wires the tenant store into the API server after construction.
|
||||
func (s *Server) SetTenants(ts *tenantstore.Store) {
|
||||
s.tenantStore = ts
|
||||
}
|
||||
|
||||
// SetLDAP wires the per-tenant LDAP config store and authenticator into the
|
||||
// API server. Both may be nil (LDAP unconfigured); the config endpoints then
|
||||
// respond 503.
|
||||
func (s *Server) SetLDAP(store *ldapstore.Store, authn *ldapauth.Authenticator) {
|
||||
s.ldapStore = store
|
||||
s.ldapAuth = authn
|
||||
}
|
||||
|
||||
// SetMailer wires the outbound mailer into the API server.
|
||||
func (s *Server) SetMailer(m *mailer.Mailer) {
|
||||
s.mailer = m
|
||||
}
|
||||
|
||||
// SetFQDN wires the server FQDN for link generation in emails.
|
||||
func (s *Server) SetFQDN(fqdn string) {
|
||||
s.fqdn = fqdn
|
||||
}
|
||||
|
||||
// SetVersion wires the app version into the API server.
|
||||
func (s *Server) SetVersion(v string) {
|
||||
s.appVersion = v
|
||||
}
|
||||
|
||||
// New creates and wires up a new API server.
|
||||
func New(
|
||||
cfg config.APIConfig,
|
||||
store *storage.Store,
|
||||
authMgr *auth.Manager,
|
||||
users *userstore.Store,
|
||||
audlog *audit.Logger,
|
||||
logger *slog.Logger,
|
||||
) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
authMgr: authMgr,
|
||||
users: users,
|
||||
audlog: audlog,
|
||||
logger: logger,
|
||||
mux: http.NewServeMux(),
|
||||
startTime: time.Now(),
|
||||
// 20 requests burst, refilled at 1/sec per client IP.
|
||||
shareLimiter: newIPRateLimiter(20, 1.0),
|
||||
// Batch pulls are legitimate here: 60 requests burst, refilled at 5/sec.
|
||||
accountingLimiter: newIPRateLimiter(60, 5.0),
|
||||
}
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
|
||||
// auth wraps a handler with authentication + tenant context propagation.
|
||||
func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc {
|
||||
return s.authMiddleware(s.tenantMiddleware(h))
|
||||
}
|
||||
|
||||
// authAdmin wraps a handler requiring at least domain_admin role.
|
||||
func (s *Server) authAdmin(h http.HandlerFunc) http.HandlerFunc {
|
||||
return s.authMiddleware(s.tenantMiddleware(s.requireRole(userstore.RoleDomainAdmin, h)))
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.mux.HandleFunc("GET /api/health", s.handleHealth)
|
||||
s.mux.HandleFunc("GET /api/version", s.handleVersion)
|
||||
|
||||
s.mux.HandleFunc("POST /api/auth/login", s.handleLogin)
|
||||
s.mux.HandleFunc("GET /api/auth/me", s.auth(s.handleMe))
|
||||
s.mux.HandleFunc("POST /api/auth/logout", s.auth(s.handleLogout))
|
||||
|
||||
s.mux.HandleFunc("GET /api/users", s.authAdmin(s.handleListUsers))
|
||||
s.mux.HandleFunc("POST /api/users", s.authAdmin(s.handleCreateUser))
|
||||
s.mux.HandleFunc("PATCH /api/users/{id}", s.authAdmin(s.handleUpdateUser))
|
||||
s.mux.HandleFunc("DELETE /api/users/{id}", s.authAdmin(s.handleDeleteUser))
|
||||
|
||||
s.mux.HandleFunc("GET /api/audit", s.auth(s.requireRole(userstore.RoleDomainAdmin, s.handleAuditLog)))
|
||||
|
||||
// Tenant management: superadmin-only (internal/api/tenant_handlers.go).
|
||||
s.mux.HandleFunc("POST /api/tenants", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCreateTenant)))
|
||||
s.mux.HandleFunc("GET /api/tenants", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleListTenants)))
|
||||
|
||||
// Dashboard (aggregated tenant key figures, internal/api/dashboard_handlers.go)
|
||||
s.mux.HandleFunc("GET /api/dashboard", s.auth(s.handleDashboard))
|
||||
|
||||
// Documents (core model)
|
||||
s.mux.HandleFunc("GET /api/documents", s.auth(s.handleListDocuments))
|
||||
s.mux.HandleFunc("POST /api/documents", s.auth(s.handleCreateDocument))
|
||||
s.mux.HandleFunc("POST /api/documents/upload", s.auth(s.handleUploadDocument))
|
||||
// Full-text + attribute search (internal/api/search_handlers.go). Registered
|
||||
// as a literal path; Go 1.22 ServeMux prefers it over /api/documents/{id}.
|
||||
s.mux.HandleFunc("GET /api/documents/search", s.auth(s.handleSearchDocuments))
|
||||
// Bulk-Export: ZIP mit doc-<id>/-Ordner je Dokument + index.csv
|
||||
// (internal/api/document_bulk_export_handlers.go). Literaler Pfad, daher
|
||||
// kein Konflikt mit /api/documents/{id}.
|
||||
s.mux.HandleFunc("POST /api/documents/export", s.auth(s.handleBulkExportDocuments))
|
||||
s.mux.HandleFunc("GET /api/documents/{id}", s.auth(s.handleGetDocument))
|
||||
s.mux.HandleFunc("GET /api/documents/{id}/file", s.auth(s.handleGetDocumentFile))
|
||||
s.mux.HandleFunc("GET /api/documents/{id}/thumbnail", s.auth(s.handleGetDocumentThumbnail))
|
||||
s.mux.HandleFunc("GET /api/documents/{id}/audit", s.auth(s.handleDocumentAuditLog))
|
||||
// Einzel-Dokument-Export: ZIP (Originaldatei + metadata.json + ocr_text.txt).
|
||||
s.mux.HandleFunc("GET /api/documents/{id}/export", s.auth(s.handleExportDocument))
|
||||
// OCR-Wortkoordinaten für das Text-Overlay (internal/api/ocr_word_handlers.go).
|
||||
s.mux.HandleFunc("GET /api/documents/{id}/ocr-words", s.auth(s.handleListDocumentOCRWords))
|
||||
s.mux.HandleFunc("PATCH /api/documents/{id}", s.auth(s.handleUpdateDocumentTitle))
|
||||
s.mux.HandleFunc("PUT /api/documents/{id}/doc-type", s.auth(s.handleSetDocumentDocType))
|
||||
s.mux.HandleFunc("PUT /api/documents/{id}/correspondent", s.auth(s.handleSetDocumentCorrespondent))
|
||||
s.mux.HandleFunc("PUT /api/documents/{id}/document-date", s.auth(s.handleSetDocumentDate))
|
||||
s.mux.HandleFunc("DELETE /api/documents/{id}", s.auth(s.handleDeleteDocument))
|
||||
s.mux.HandleFunc("POST /api/documents/{id}/reprocess", s.auth(s.handleReprocessDocument))
|
||||
// Status/manueller Retry der asynchronen Verarbeitungswarteschlange
|
||||
// (internal/api/processing_job_handlers.go). Das Frontend pollt den
|
||||
// GET-Endpunkt nur solange ein Dokument nicht 'done' ist.
|
||||
s.mux.HandleFunc("GET /api/documents/{id}/processing-job", s.auth(s.handleGetProcessingJob))
|
||||
s.mux.HandleFunc("POST /api/documents/{id}/processing-job/retry", s.auth(s.handleRetryProcessingJob))
|
||||
// Akte-Zuordnung eines Dokuments (internal/api/akte_handlers.go). Strikt
|
||||
// 1:n: Zuordnung ist nur documents.akte_id setzen/nullen.
|
||||
s.mux.HandleFunc("PUT /api/documents/{id}/akte", s.auth(s.handleSetDocumentAkte))
|
||||
// Freitext-Notizen pro Dokument (internal/api/document_note_handlers.go).
|
||||
s.mux.HandleFunc("GET /api/documents/{id}/notes", s.auth(s.handleListDocumentNotes))
|
||||
s.mux.HandleFunc("POST /api/documents/{id}/notes", s.auth(s.handleCreateDocumentNote))
|
||||
s.mux.HandleFunc("DELETE /api/documents/{id}/notes/{noteId}", s.auth(s.handleDeleteDocumentNote))
|
||||
|
||||
// Gespeicherte Suchansichten (SavedViews, Paperless-ngx inspiriert —
|
||||
// internal/api/saved_view_handlers.go). Tenant-/user-weit, nicht
|
||||
// dokument-gebunden, daher eigener Block. Liste enthält eigene + geteilte
|
||||
// Views; PATCH/DELETE nur durch den Ersteller.
|
||||
s.mux.HandleFunc("GET /api/saved-views", s.auth(s.handleListSavedViews))
|
||||
s.mux.HandleFunc("POST /api/saved-views", s.auth(s.handleCreateSavedView))
|
||||
s.mux.HandleFunc("PATCH /api/saved-views/{id}", s.auth(s.handleUpdateSavedView))
|
||||
s.mux.HandleFunc("DELETE /api/saved-views/{id}", s.auth(s.handleDeleteSavedView))
|
||||
|
||||
// Digitale Akten (digitaler Aktenordner — internal/api/akte_handlers.go).
|
||||
// Strikt 1:n zu Dokumenten via documents.akte_id. Keine eigene ACL — die
|
||||
// Sichtbarkeit erbt von den enthaltenen Dokumenten (GET .../{id} liefert die
|
||||
// ACL-gefilterte Dokumentliste). Tenant-scoped (s.auth).
|
||||
s.mux.HandleFunc("GET /api/akten", s.auth(s.handleListAkten))
|
||||
s.mux.HandleFunc("POST /api/akten", s.auth(s.handleCreateAkte))
|
||||
s.mux.HandleFunc("GET /api/akten/{id}", s.auth(s.handleGetAkte))
|
||||
s.mux.HandleFunc("PATCH /api/akten/{id}", s.auth(s.handleUpdateAkte))
|
||||
s.mux.HandleFunc("POST /api/akten/{id}/close", s.auth(s.handleCloseAkte))
|
||||
s.mux.HandleFunc("DELETE /api/akten/{id}", s.auth(s.handleDeleteAkte))
|
||||
|
||||
// Trash + gestaffeltes Löschkonzept (internal/api/trash_handlers.go).
|
||||
// DELETE /api/documents/{id} above is now a soft-delete into the trash.
|
||||
s.mux.HandleFunc("GET /api/trash", s.auth(s.handleListTrash))
|
||||
s.mux.HandleFunc("POST /api/trash/{id}/restore", s.auth(s.handleRestoreDocument))
|
||||
s.mux.HandleFunc("GET /api/trash/{id}/delete-requests", s.auth(s.handleListDeleteRequests))
|
||||
s.mux.HandleFunc("POST /api/trash/{id}/delete-requests", s.auth(s.handleCreateDeleteRequest))
|
||||
s.mux.HandleFunc("DELETE /api/trash/{id}/delete-requests/{reqId}", s.auth(s.handleCancelDeleteRequest))
|
||||
// Confirm executes the physical deletion -> domain_admin (User B) required.
|
||||
s.mux.HandleFunc("POST /api/trash/{id}/delete-requests/{reqId}/confirm", s.authAdmin(s.handleConfirmDeleteRequest))
|
||||
|
||||
// Wiedervorlage (reminders)
|
||||
s.mux.HandleFunc("POST /api/documents/{id}/reminders", s.auth(s.handleCreateReminder))
|
||||
s.mux.HandleFunc("GET /api/reminders", s.auth(s.handleListReminders))
|
||||
s.mux.HandleFunc("PATCH /api/reminders/{id}", s.auth(s.handleUpdateReminder))
|
||||
s.mux.HandleFunc("DELETE /api/reminders/{id}", s.auth(s.handleDeleteReminder))
|
||||
|
||||
// Structured taxonomy entities (tags/document_types/correspondents)
|
||||
s.mux.HandleFunc("GET /api/tags", s.auth(s.handleListTaxonomy("tags")))
|
||||
s.mux.HandleFunc("POST /api/tags", s.auth(s.handleCreateTaxonomy("tags")))
|
||||
s.mux.HandleFunc("PATCH /api/tags/{id}", s.auth(s.handleUpdateTaxonomy("tags")))
|
||||
s.mux.HandleFunc("DELETE /api/tags/{id}", s.auth(s.handleDeleteTaxonomy("tags")))
|
||||
|
||||
s.mux.HandleFunc("GET /api/document-types", s.auth(s.handleListTaxonomy("document_types")))
|
||||
s.mux.HandleFunc("POST /api/document-types", s.auth(s.handleCreateTaxonomy("document_types")))
|
||||
s.mux.HandleFunc("PATCH /api/document-types/{id}", s.auth(s.handleUpdateTaxonomy("document_types")))
|
||||
s.mux.HandleFunc("DELETE /api/document-types/{id}", s.auth(s.handleDeleteTaxonomy("document_types")))
|
||||
|
||||
s.mux.HandleFunc("GET /api/correspondents", s.auth(s.handleListTaxonomy("correspondents")))
|
||||
s.mux.HandleFunc("POST /api/correspondents", s.auth(s.handleCreateTaxonomy("correspondents")))
|
||||
s.mux.HandleFunc("PATCH /api/correspondents/{id}", s.auth(s.handleUpdateTaxonomy("correspondents")))
|
||||
s.mux.HandleFunc("DELETE /api/correspondents/{id}", s.auth(s.handleDeleteTaxonomy("correspondents")))
|
||||
|
||||
// Manual tag attach/detach on a document
|
||||
s.mux.HandleFunc("GET /api/documents/{id}/tags", s.auth(s.handleListDocumentTags))
|
||||
s.mux.HandleFunc("POST /api/documents/{id}/tags/{tagId}", s.auth(s.handleAttachTag))
|
||||
s.mux.HandleFunc("DELETE /api/documents/{id}/tags/{tagId}", s.auth(s.handleDetachTag))
|
||||
|
||||
// Custom fields (definitions, document-type assignments, document values)
|
||||
s.mux.HandleFunc("GET /api/custom-fields", s.auth(s.handleListCustomFields))
|
||||
s.mux.HandleFunc("POST /api/custom-fields", s.authAdmin(s.handleCreateCustomField))
|
||||
s.mux.HandleFunc("PATCH /api/custom-fields/{id}", s.authAdmin(s.handleUpdateCustomField))
|
||||
s.mux.HandleFunc("DELETE /api/custom-fields/{id}", s.authAdmin(s.handleDeleteCustomField))
|
||||
|
||||
s.mux.HandleFunc("GET /api/document-types/{id}/fields", s.auth(s.handleListDocumentTypeFields))
|
||||
s.mux.HandleFunc("PUT /api/document-types/{id}/fields", s.authAdmin(s.handleSetDocumentTypeFields))
|
||||
|
||||
s.mux.HandleFunc("GET /api/documents/{id}/fields", s.auth(s.handleListDocumentFieldValues))
|
||||
s.mux.HandleFunc("PUT /api/documents/{id}/fields", s.auth(s.handleSetDocumentFieldValues))
|
||||
|
||||
// Classification templates (Klassifizierungsvorlagen,
|
||||
// internal/api/classification_template_handlers.go). CRUD + tag / field-
|
||||
// default bulk replace are domain_admin-only (s.authAdmin); applying a
|
||||
// template to a document is a normal authenticated working action (s.auth).
|
||||
s.mux.HandleFunc("GET /api/classification-templates", s.auth(s.handleListTemplates))
|
||||
s.mux.HandleFunc("POST /api/classification-templates", s.authAdmin(s.handleCreateTemplate))
|
||||
s.mux.HandleFunc("GET /api/classification-templates/{id}", s.auth(s.handleGetTemplate))
|
||||
s.mux.HandleFunc("PUT /api/classification-templates/{id}", s.authAdmin(s.handleUpdateTemplate))
|
||||
s.mux.HandleFunc("DELETE /api/classification-templates/{id}", s.authAdmin(s.handleDeleteTemplate))
|
||||
s.mux.HandleFunc("PUT /api/classification-templates/{id}/tags", s.authAdmin(s.handleSetTemplateTags))
|
||||
s.mux.HandleFunc("PUT /api/classification-templates/{id}/field-defaults", s.authAdmin(s.handleSetTemplateFieldDefaults))
|
||||
s.mux.HandleFunc("POST /api/documents/{id}/apply-template", s.auth(s.handleApplyTemplate))
|
||||
|
||||
// GoBD-Aufbewahrungsregeln (internal/api/retention_rule_handlers.go).
|
||||
// Lesen (Liste/eligible/preview) ist normale Tenant-Aktion; Anlegen/Ändern/
|
||||
// Löschen ist compliance-kritisch und erfordert domain_admin.
|
||||
s.mux.HandleFunc("GET /api/retention-rules", s.auth(s.handleListRetentionRules))
|
||||
s.mux.HandleFunc("POST /api/retention-rules", s.authAdmin(s.handleCreateRetentionRule))
|
||||
s.mux.HandleFunc("GET /api/retention-rules/eligible", s.auth(s.handleListEligibleForDisposition))
|
||||
s.mux.HandleFunc("GET /api/retention-rules/preview", s.auth(s.handlePreviewRetentionRules))
|
||||
s.mux.HandleFunc("PATCH /api/retention-rules/{id}", s.authAdmin(s.handleUpdateRetentionRule))
|
||||
s.mux.HandleFunc("DELETE /api/retention-rules/{id}", s.authAdmin(s.handleDeleteRetentionRule))
|
||||
|
||||
// GoBD-Verfahrensdokumentation als Markdown-Entwurf
|
||||
// (internal/api/compliance_handlers.go). domain_admin+ für den eigenen
|
||||
// Mandanten; superadmin darf per ?tenant_id=N einen fremden Mandanten
|
||||
// exportieren (Prüfung im Handler).
|
||||
s.mux.HandleFunc("GET /api/compliance/procedure-documentation", s.authAdmin(s.handleProcedureDocumentation))
|
||||
|
||||
// Workflows / Consumption-Regeln (internal/api/workflow_handlers.go).
|
||||
// Administration (CRUD + action bulk replace) is domain_admin-only
|
||||
// (s.authAdmin); the dry-run test and the runs overview are normal
|
||||
// authenticated tenant actions (s.auth). Automatic on_upload execution is
|
||||
// wired into the upload pipeline (storeUploadedFile), not exposed as a route.
|
||||
s.mux.HandleFunc("GET /api/workflows", s.auth(s.handleListWorkflows))
|
||||
s.mux.HandleFunc("POST /api/workflows", s.authAdmin(s.handleCreateWorkflow))
|
||||
s.mux.HandleFunc("GET /api/workflows/{id}", s.auth(s.handleGetWorkflow))
|
||||
s.mux.HandleFunc("PUT /api/workflows/{id}", s.authAdmin(s.handleUpdateWorkflow))
|
||||
s.mux.HandleFunc("DELETE /api/workflows/{id}", s.authAdmin(s.handleDeleteWorkflow))
|
||||
s.mux.HandleFunc("PUT /api/workflows/{id}/actions", s.authAdmin(s.handleSetWorkflowActions))
|
||||
s.mux.HandleFunc("POST /api/workflows/{id}/test", s.auth(s.handleTestWorkflow))
|
||||
s.mux.HandleFunc("GET /api/workflows/{id}/runs", s.auth(s.handleListWorkflowRuns))
|
||||
|
||||
// Heuristische Metadaten-Vorschläge (internal/api/metadata_suggestion_handlers.go).
|
||||
// All three are normal authenticated tenant actions; nothing here applies a
|
||||
// suggestion — accepting a suggested field goes through the normal edit
|
||||
// endpoints (PATCH title, tag-attach, ...).
|
||||
s.mux.HandleFunc("POST /api/documents/{id}/suggest-metadata", s.auth(s.handleGenerateSuggestions))
|
||||
s.mux.HandleFunc("GET /api/documents/{id}/suggest-metadata", s.auth(s.handleGetLatestSuggestion))
|
||||
s.mux.HandleFunc("POST /api/documents/{id}/suggest-metadata/{suggestionId}/reviewed", s.auth(s.handleMarkSuggestionReviewed))
|
||||
|
||||
// Permission model (group-resolved document ACL, internal/api/permission_handlers.go).
|
||||
// Group + grant administration is domain_admin-only (s.authAdmin).
|
||||
s.mux.HandleFunc("GET /api/permission-groups", s.authAdmin(s.handleListPermissionGroups))
|
||||
s.mux.HandleFunc("POST /api/permission-groups", s.authAdmin(s.handleCreatePermissionGroup))
|
||||
s.mux.HandleFunc("DELETE /api/permission-groups/{id}", s.authAdmin(s.handleDeletePermissionGroup))
|
||||
s.mux.HandleFunc("GET /api/permission-groups/{id}/members", s.authAdmin(s.handleListGroupMembers))
|
||||
s.mux.HandleFunc("POST /api/permission-groups/{id}/members", s.authAdmin(s.handleAddGroupMember))
|
||||
s.mux.HandleFunc("DELETE /api/permission-groups/{id}/members/{userId}", s.authAdmin(s.handleRemoveGroupMember))
|
||||
|
||||
s.mux.HandleFunc("GET /api/document-types/{id}/grants", s.authAdmin(s.handleListDocumentTypeGrants))
|
||||
s.mux.HandleFunc("POST /api/document-types/{id}/grants", s.authAdmin(s.handleSetDocumentTypeGrant))
|
||||
s.mux.HandleFunc("DELETE /api/document-types/{id}/grants", s.authAdmin(s.handleDeleteDocumentTypeGrant))
|
||||
s.mux.HandleFunc("GET /api/tags/{id}/grants", s.authAdmin(s.handleListTagGrants))
|
||||
s.mux.HandleFunc("POST /api/tags/{id}/grants", s.authAdmin(s.handleSetTagGrant))
|
||||
s.mux.HandleFunc("DELETE /api/tags/{id}/grants", s.authAdmin(s.handleDeleteTagGrant))
|
||||
s.mux.HandleFunc("GET /api/documents/{id}/grants", s.authAdmin(s.handleListDocumentGrants))
|
||||
s.mux.HandleFunc("POST /api/documents/{id}/grants", s.authAdmin(s.handleSetDocumentGrant))
|
||||
s.mux.HandleFunc("DELETE /api/documents/{id}/grants", s.authAdmin(s.handleDeleteDocumentGrant))
|
||||
|
||||
// External share-links (internal/api/share_handlers.go). Create/list/revoke
|
||||
// are authenticated + tenant-scoped; the tenant-wide overview is domain_admin.
|
||||
s.mux.HandleFunc("POST /api/documents/{id}/shares", s.auth(s.handleCreateShare))
|
||||
s.mux.HandleFunc("GET /api/documents/{id}/shares", s.auth(s.handleListDocumentShares))
|
||||
s.mux.HandleFunc("DELETE /api/shares/{share_id}", s.auth(s.handleRevokeShare))
|
||||
s.mux.HandleFunc("GET /api/shares", s.authAdmin(s.handleListTenantShares))
|
||||
|
||||
// Public share endpoints (internal/api/public_share_handlers.go) — served
|
||||
// WITHOUT the s.auth wrapper by design: the share token is the credential.
|
||||
// Lookup is always by token_hash, rate-limited per IP, every attempt logged.
|
||||
s.mux.HandleFunc("GET /public/share/{token}", s.handlePublicShareMeta)
|
||||
s.mux.HandleFunc("POST /public/share/{token}/download", s.handlePublicShareDownload)
|
||||
|
||||
// Buchhaltungs-Pull-API (internal/api/accounting_handlers.go).
|
||||
// Key administration runs on the normal session auth and is domain_admin-only
|
||||
// (a key grants tenant-wide read access to archived documents).
|
||||
s.mux.HandleFunc("POST /api/accounting/api-keys", s.authAdmin(s.handleCreateAccountingAPIKey))
|
||||
s.mux.HandleFunc("GET /api/accounting/api-keys", s.authAdmin(s.handleListAccountingAPIKeys))
|
||||
s.mux.HandleFunc("DELETE /api/accounting/api-keys/{id}", s.authAdmin(s.handleRevokeAccountingAPIKey))
|
||||
|
||||
// The pull endpoints themselves are served WITHOUT s.auth by design: the
|
||||
// Authorization: Bearer <key> API key is the credential, and the tenant id
|
||||
// comes exclusively from resolving that key (s.accountingAuth) — never from
|
||||
// a query parameter.
|
||||
s.mux.HandleFunc("GET /api/v1/accounting/documents", s.accountingAuth(s.handleAccountingListDocuments))
|
||||
s.mux.HandleFunc("GET /api/v1/accounting/documents/{id}/file", s.accountingAuth(s.handleAccountingDocumentFile))
|
||||
|
||||
// Per-tenant LDAP directory config (internal/api/ldap_handlers.go).
|
||||
// domain_admin manages its own tenant; superadmin may target any tenant
|
||||
// via ?tenant_id=. The bind password is never returned.
|
||||
s.mux.HandleFunc("GET /api/ldap-config", s.authAdmin(s.handleGetLDAPConfig))
|
||||
s.mux.HandleFunc("PUT /api/ldap-config", s.authAdmin(s.handleUpsertLDAPConfig))
|
||||
|
||||
// Per-tenant settings (internal/api/tenant_settings_handlers.go).
|
||||
// domain_admin manages its own tenant; superadmin may target any tenant
|
||||
// via ?tenant_id=. Currently: the placeholder-title date format.
|
||||
s.mux.HandleFunc("GET /api/tenant-settings", s.authAdmin(s.handleGetTenantSettings))
|
||||
s.mux.HandleFunc("PUT /api/tenant-settings", s.authAdmin(s.handleUpdateTenantSettings))
|
||||
|
||||
// Per-tenant external-Ollama connection config (internal/api/ollama_config_handlers.go).
|
||||
// domain_admin manages its own tenant; superadmin may target any tenant via
|
||||
// ?tenant_id=. Gates the optional 'ollama' metadata-suggestion provider.
|
||||
s.mux.HandleFunc("GET /api/ollama-config", s.authAdmin(s.handleGetOllamaConfig))
|
||||
s.mux.HandleFunc("PUT /api/ollama-config", s.authAdmin(s.handleUpsertOllamaConfig))
|
||||
s.mux.HandleFunc("GET /api/ollama-config/models", s.authAdmin(s.handleListOllamaModels))
|
||||
|
||||
// SFTP credentials (embedded per-tenant SFTP server, internal/sftpserver)
|
||||
s.mux.HandleFunc("POST /api/admin/sftp-credentials", s.authAdmin(s.handleCreateSFTPCredential))
|
||||
s.mux.HandleFunc("GET /api/admin/sftp-credentials", s.authAdmin(s.handleListSFTPCredentials))
|
||||
s.mux.HandleFunc("DELETE /api/admin/sftp-credentials/{id}", s.authAdmin(s.handleRevokeSFTPCredential))
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// --- system handlers ---
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"version": s.appVersion})
|
||||
}
|
||||
|
||||
// --- middleware ---
|
||||
|
||||
const sessionCookieName = "archivdms_session"
|
||||
|
||||
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := ""
|
||||
if c, err := r.Cookie(sessionCookieName); err == nil {
|
||||
token = c.Value
|
||||
}
|
||||
if token == "" {
|
||||
token = extractBearerToken(r)
|
||||
}
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing authorization")
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := s.authMgr.ValidateToken(token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), sessionKey, sess)
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) requireRole(role string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess == nil || !auth.HasRole(sess.Role, role) {
|
||||
writeError(w, http.StatusForbidden, "insufficient permissions")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||
writeJSON(w, code, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func extractBearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(h, "Bearer ") {
|
||||
return strings.TrimPrefix(h, "Bearer ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sessionFromCtx(ctx context.Context) *auth.Session {
|
||||
v := ctx.Value(sessionKey)
|
||||
if v == nil {
|
||||
return &auth.Session{}
|
||||
}
|
||||
if s, ok := v.(*auth.Session); ok {
|
||||
return s
|
||||
}
|
||||
return &auth.Session{}
|
||||
}
|
||||
|
||||
// tenantMiddleware extracts the tenant_id from the session and stores it in
|
||||
// the request context, making it available to all downstream handlers.
|
||||
func (s *Server) tenantMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := sessionFromCtx(r.Context())
|
||||
if session != nil && session.TenantID != nil {
|
||||
ctx := context.WithValue(r.Context(), tenantKey, session.TenantID)
|
||||
next(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// tenantFromCtx extracts the tenant_id from context. Returns nil for a
|
||||
// global (superadmin/tenant-less) context.
|
||||
func tenantFromCtx(ctx context.Context) *int64 {
|
||||
v, _ := ctx.Value(tenantKey).(*int64)
|
||||
return v
|
||||
}
|
||||
|
||||
// remoteIP returns the real client IP. X-Forwarded-For is only trusted when
|
||||
// the direct connection comes from a configured trusted proxy.
|
||||
func (s *Server) remoteIP(r *http.Request) string {
|
||||
directIP, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
if directIP == "" {
|
||||
directIP = r.RemoteAddr
|
||||
}
|
||||
if len(s.cfg.TrustedProxies) > 0 && isTrustedProxy(directIP, s.cfg.TrustedProxies) {
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
return strings.TrimSpace(strings.Split(fwd, ",")[0])
|
||||
}
|
||||
}
|
||||
return directIP
|
||||
}
|
||||
|
||||
func isTrustedProxy(ip string, proxies []string) bool {
|
||||
parsed := net.ParseIP(ip)
|
||||
for _, p := range proxies {
|
||||
if strings.Contains(p, "/") {
|
||||
_, cidr, err := net.ParseCIDR(p)
|
||||
if err == nil && cidr.Contains(parsed) {
|
||||
return true
|
||||
}
|
||||
} else if p == ip {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
// --- Admin CRUD for per-tenant SFTP credentials (internal/sftpserver) ---
|
||||
//
|
||||
// Registered behind s.authAdmin (domain_admin/superadmin), tenant-scoped the
|
||||
// same way handleListUsers/handleCreateUser are: sess.TenantID (from the
|
||||
// domain_admin's own session) picks the tenant for domain admins, while a
|
||||
// tenant-less (superadmin) session must specify tenant_id explicitly on
|
||||
// create and cannot list/revoke without one — SFTP credentials always
|
||||
// belong to exactly one tenant's inbox.
|
||||
|
||||
type createSFTPCredentialRequest struct {
|
||||
Username string `json:"username"`
|
||||
TenantID *int64 `json:"tenant_id,omitempty"` // required only for tenant-less (superadmin) sessions
|
||||
}
|
||||
|
||||
type sftpCredentialCreatedResponse struct {
|
||||
storage.SFTPCredential
|
||||
Password string `json:"password"` // returned exactly once, in this response only
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateSFTPCredential(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
|
||||
var req createSFTPCredentialRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Username == "" {
|
||||
writeError(w, http.StatusBadRequest, "username is required")
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := sess.TenantID
|
||||
if tenantID == nil {
|
||||
tenantID = req.TenantID
|
||||
}
|
||||
if tenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "tenant_id is required for tenant-less sessions")
|
||||
return
|
||||
}
|
||||
|
||||
password, err := randomSFTPPassword()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "generate password failed")
|
||||
return
|
||||
}
|
||||
|
||||
cred, err := s.store.CreateSFTPCredential(r.Context(), *tenantID, req.Username, password)
|
||||
if err != nil {
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventSFTPCredentialCreate, Username: sess.Username, TenantID: tenantID, Success: false, Detail: err.Error()})
|
||||
writeError(w, http.StatusBadRequest, "create sftp credential failed (username may already be taken)")
|
||||
return
|
||||
}
|
||||
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventSFTPCredentialCreate, Username: sess.Username, TenantID: tenantID,
|
||||
Success: true, Detail: "sftp_username:" + cred.Username,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusCreated, sftpCredentialCreatedResponse{SFTPCredential: *cred, Password: password})
|
||||
}
|
||||
|
||||
func (s *Server) handleListSFTPCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
creds, err := s.store.ListSFTPCredentials(r.Context(), *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list sftp credentials failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, creds)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeSFTPCredential(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid credential id")
|
||||
return
|
||||
}
|
||||
if err := s.store.RevokeSFTPCredential(r.Context(), id, *sess.TenantID); err != nil {
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventSFTPCredentialRevoke, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
|
||||
writeError(w, http.StatusNotFound, "sftp credential not found")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventSFTPCredentialRevoke, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||
}
|
||||
|
||||
func randomSFTPPassword() (string, error) {
|
||||
b := make([]byte, 20)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Authenticated share-link handlers (see internal/storage/shares.go and the
|
||||
// public counterpart in public_share_handlers.go):
|
||||
//
|
||||
// POST /api/documents/{id}/shares create a share (expires_at required)
|
||||
// GET /api/documents/{id}/shares list shares for a document
|
||||
// DELETE /api/shares/{share_id} revoke a share (soft, never hard-delete)
|
||||
// GET /api/shares all shares of the tenant (domain_admin+)
|
||||
//
|
||||
// Ownership is enforced in the store layer (document/share id + tenant_id), the
|
||||
// same IDOR guard used by the other document endpoints. Every create/revoke is
|
||||
// audit-logged (EventShareCreated/EventShareRevoked), including failures. The
|
||||
// raw token is returned exactly once, in the create response.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
// createShareRequest is the POST body for creating a share. ExpiresAt is
|
||||
// mandatory (no unbounded shares); MaxAccesses and Password are optional.
|
||||
type createShareRequest struct {
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
MaxAccesses *int `json:"max_accesses,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// createShareResponse embeds the stored share plus the one-time plaintext
|
||||
// token (only ever returned here).
|
||||
type createShareResponse struct {
|
||||
storage.DocumentShare
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func (s *Server) logShare(r *http.Request, event string, tenantID *int64, username, detail string, ok bool) {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: event, Username: username, TenantID: tenantID,
|
||||
IPAddress: s.remoteIP(r), Success: ok, Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
// handleCreateShare handles POST /api/documents/{id}/shares.
|
||||
func (s *Server) handleCreateShare(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
var req createShareRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.ExpiresAt.IsZero() {
|
||||
writeError(w, http.StatusBadRequest, "expires_at is required")
|
||||
return
|
||||
}
|
||||
if !req.ExpiresAt.After(time.Now()) {
|
||||
writeError(w, http.StatusBadRequest, "expires_at must be in the future")
|
||||
return
|
||||
}
|
||||
if req.MaxAccesses != nil && *req.MaxAccesses < 1 {
|
||||
writeError(w, http.StatusBadRequest, "max_accesses must be at least 1")
|
||||
return
|
||||
}
|
||||
|
||||
share, token, err := s.store.CreateShare(r.Context(), storage.CreateShareRequest{
|
||||
TenantID: *sess.TenantID,
|
||||
DocumentID: docID,
|
||||
CreatedBy: sess.UserID,
|
||||
ExpiresAt: req.ExpiresAt,
|
||||
MaxAccesses: req.MaxAccesses,
|
||||
Password: req.Password,
|
||||
})
|
||||
if err != nil {
|
||||
s.logShare(r, audit.EventShareCreated, sess.TenantID, sess.Username, "share_create doc:"+strconv.FormatInt(docID, 10)+" err:"+err.Error(), false)
|
||||
writeError(w, shareStatus(err), "create share failed")
|
||||
return
|
||||
}
|
||||
s.logShare(r, audit.EventShareCreated, sess.TenantID, sess.Username, "share_create doc:"+strconv.FormatInt(docID, 10)+" share:"+strconv.FormatInt(share.ID, 10), true)
|
||||
writeJSON(w, http.StatusCreated, createShareResponse{DocumentShare: *share, Token: token})
|
||||
}
|
||||
|
||||
// handleListDocumentShares handles GET /api/documents/{id}/shares.
|
||||
func (s *Server) handleListDocumentShares(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
shares, err := s.store.ListSharesForDocument(r.Context(), docID, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list shares failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, shares)
|
||||
}
|
||||
|
||||
// handleRevokeShare handles DELETE /api/shares/{share_id}.
|
||||
func (s *Server) handleRevokeShare(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
shareID, err := strconv.ParseInt(r.PathValue("share_id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid share id")
|
||||
return
|
||||
}
|
||||
if err := s.store.RevokeShare(r.Context(), shareID, *sess.TenantID, sess.UserID); err != nil {
|
||||
s.logShare(r, audit.EventShareRevoked, sess.TenantID, sess.Username, "share_revoke share:"+strconv.FormatInt(shareID, 10)+" err:"+err.Error(), false)
|
||||
writeError(w, shareStatus(err), "revoke share failed")
|
||||
return
|
||||
}
|
||||
s.logShare(r, audit.EventShareRevoked, sess.TenantID, sess.Username, "share_revoke share:"+strconv.FormatInt(shareID, 10), true)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||
}
|
||||
|
||||
// handleListTenantShares handles GET /api/shares (domain_admin+): every share
|
||||
// of the caller's tenant, document title joined in.
|
||||
func (s *Server) handleListTenantShares(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
shares, err := s.store.ListSharesForTenant(r.Context(), *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list shares failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, shares)
|
||||
}
|
||||
|
||||
// shareStatus maps store errors to an HTTP status for the authenticated
|
||||
// endpoints.
|
||||
func shareStatus(err error) int {
|
||||
if errors.Is(err, storage.ErrShareNotFound) {
|
||||
return http.StatusNotFound
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// Structured-entity HTTP handlers for tags/document_types/correspondents
|
||||
// (see internal/storage/taxonomy.go) plus manual tag attach/detach:
|
||||
//
|
||||
// GET/POST /api/tags PATCH/DELETE /api/tags/{id}
|
||||
// GET/POST /api/document-types PATCH/DELETE /api/document-types/{id}
|
||||
// GET/POST /api/correspondents PATCH/DELETE /api/correspondents/{id}
|
||||
// POST/DELETE /api/documents/{id}/tags/{tagId}
|
||||
//
|
||||
// All routes require s.auth(...) (authenticated + tenant context).
|
||||
// Ownership is enforced in the store layer (id+tenant_id), analogous to
|
||||
// internal/api/reminder_handlers.go. Every mutation is audit-logged,
|
||||
// including failures.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
type taxonomyEntityRequest struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
MatchAlgorithm string `json:"match_algorithm"`
|
||||
MatchPattern string `json:"match_pattern"`
|
||||
CaseSensitive bool `json:"case_sensitive"`
|
||||
BarcodeValue string `json:"barcode_value"`
|
||||
}
|
||||
|
||||
func taxonomyEventType(kind string) string {
|
||||
switch kind {
|
||||
case "tags":
|
||||
return "tag"
|
||||
case "document_types":
|
||||
return "document_type"
|
||||
case "correspondents":
|
||||
return "correspondent"
|
||||
default:
|
||||
return kind
|
||||
}
|
||||
}
|
||||
|
||||
// handleListTaxonomy handles GET /api/{tags,document-types,correspondents}.
|
||||
func (s *Server) handleListTaxonomy(kind string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
entities, err := s.store.ListTaxonomyEntities(r.Context(), kind, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list "+kind+" failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entities)
|
||||
}
|
||||
}
|
||||
|
||||
// handleCreateTaxonomy handles POST /api/{tags,document-types,correspondents}.
|
||||
func (s *Server) handleCreateTaxonomy(kind string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
var req taxonomyEntityRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
|
||||
entity, err := s.store.CreateTaxonomyEntity(r.Context(), kind, *sess.TenantID, storage.TaxonomyEntityRequest{
|
||||
Name: req.Name, Color: req.Color, MatchAlgorithm: req.MatchAlgorithm,
|
||||
MatchPattern: req.MatchPattern, CaseSensitive: req.CaseSensitive, BarcodeValue: req.BarcodeValue,
|
||||
})
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, storage.ErrDuplicateTaxonomyName) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: taxonomyEventType(kind) + "_create", Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: err.Error()})
|
||||
writeError(w, status, "create "+kind+" failed")
|
||||
return
|
||||
}
|
||||
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: taxonomyEventType(kind) + "_create", Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "id:" + strconv.FormatInt(entity.ID, 10) + " name:" + entity.Name,
|
||||
})
|
||||
writeJSON(w, http.StatusCreated, entity)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUpdateTaxonomy handles PATCH /api/{tags,document-types,correspondents}/{id}.
|
||||
func (s *Server) handleUpdateTaxonomy(kind string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var req taxonomyEntityRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
|
||||
entity, err := s.store.UpdateTaxonomyEntity(r.Context(), kind, id, *sess.TenantID, storage.TaxonomyEntityRequest{
|
||||
Name: req.Name, Color: req.Color, MatchAlgorithm: req.MatchAlgorithm,
|
||||
MatchPattern: req.MatchPattern, CaseSensitive: req.CaseSensitive, BarcodeValue: req.BarcodeValue,
|
||||
})
|
||||
if err != nil {
|
||||
status := http.StatusNotFound
|
||||
if errors.Is(err, storage.ErrDuplicateTaxonomyName) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: taxonomyEventType(kind) + "_update", Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: false, Detail: "id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(),
|
||||
})
|
||||
writeError(w, status, "update "+kind+" failed")
|
||||
return
|
||||
}
|
||||
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: taxonomyEventType(kind) + "_update", Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "id:" + strconv.FormatInt(entity.ID, 10),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, entity)
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteTaxonomy handles DELETE /api/{tags,document-types,correspondents}/{id}.
|
||||
func (s *Server) handleDeleteTaxonomy(kind string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteTaxonomyEntity(r.Context(), kind, id, *sess.TenantID); err != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: taxonomyEventType(kind) + "_delete", Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: false, Detail: "id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(),
|
||||
})
|
||||
writeError(w, http.StatusNotFound, "delete "+kind+" failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: taxonomyEventType(kind) + "_delete", Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "id:" + strconv.FormatInt(id, 10),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
}
|
||||
|
||||
// handleAttachTag handles POST /api/documents/{id}/tags/{tagId} (manual
|
||||
// tag attach). Verifies both the document and the tag belong to the
|
||||
// caller's tenant before inserting the document_tags row.
|
||||
func (s *Server) handleAttachTag(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
tagID, err := strconv.ParseInt(r.PathValue("tagId"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid tag id")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.GetDocument(r.Context(), docID, *sess.TenantID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
tags, err := s.store.ListTaxonomyEntities(r.Context(), "tags", *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "attach tag failed")
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for _, t := range tags {
|
||||
if t.ID == tagID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "tag not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.AttachTag(r.Context(), docID, tagID); err != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: "tag_attach", Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: strconv.FormatInt(docID, 10), Success: false, Detail: err.Error(),
|
||||
})
|
||||
writeError(w, http.StatusInternalServerError, "attach tag failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: "tag_attach", Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: strconv.FormatInt(docID, 10), Success: true, Detail: "tag_id:" + strconv.FormatInt(tagID, 10),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "attached"})
|
||||
}
|
||||
|
||||
// handleDetachTag handles DELETE /api/documents/{id}/tags/{tagId}.
|
||||
func (s *Server) handleDetachTag(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
tagID, err := strconv.ParseInt(r.PathValue("tagId"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid tag id")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.GetDocument(r.Context(), docID, *sess.TenantID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.DetachTag(r.Context(), docID, tagID); err != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: "tag_detach", Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: strconv.FormatInt(docID, 10), Success: false, Detail: err.Error(),
|
||||
})
|
||||
writeError(w, http.StatusInternalServerError, "detach tag failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: "tag_detach", Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: strconv.FormatInt(docID, 10), Success: true, Detail: "tag_id:" + strconv.FormatInt(tagID, 10),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "detached"})
|
||||
}
|
||||
|
||||
// handleListDocumentTags handles GET /api/documents/{id}/tags.
|
||||
func (s *Server) handleListDocumentTags(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
tags, err := s.store.ListDocumentTags(r.Context(), docID, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list document tags failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tags)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
)
|
||||
|
||||
type createTenantRequest struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
|
||||
// handleCreateTenant creates a new tenant. Restricted to superadmin via
|
||||
// requireRole in server.go's route registration.
|
||||
func (s *Server) handleCreateTenant(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
var req createTenantRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Slug == "" {
|
||||
writeError(w, http.StatusBadRequest, "name and slug are required")
|
||||
return
|
||||
}
|
||||
|
||||
tenant, err := s.tenantStore.Create(r.Context(), req.Name, req.Slug, req.Domain)
|
||||
if err != nil {
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventTenantMgmt, Username: sess.Username, Success: false, Detail: "create_tenant_failed"})
|
||||
writeError(w, http.StatusBadRequest, "create tenant failed")
|
||||
return
|
||||
}
|
||||
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventTenantMgmt, Username: sess.Username, Success: true, Detail: "tenant_created:" + tenant.Slug})
|
||||
writeJSON(w, http.StatusCreated, tenant)
|
||||
}
|
||||
|
||||
// handleListTenants lists all tenants. Restricted to superadmin via
|
||||
// requireRole in server.go's route registration.
|
||||
func (s *Server) handleListTenants(w http.ResponseWriter, r *http.Request) {
|
||||
tenants, err := s.tenantStore.List(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list tenants failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tenants)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/dateformat"
|
||||
"archivdms/internal/storage"
|
||||
"archivdms/internal/userstore"
|
||||
)
|
||||
|
||||
// resolveTenantSettingsTenant determines which tenant the request targets,
|
||||
// mirroring resolveLDAPTenant: domain_admin is pinned to its own signed
|
||||
// session tenant (IDOR-safe, query params ignored); superadmin (no session
|
||||
// tenant) must pass ?tenant_id=. Returns the tenant ID and false when the
|
||||
// request is not authorised or the tenant cannot be determined (the caller
|
||||
// has already written the response).
|
||||
func (s *Server) resolveTenantSettingsTenant(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
|
||||
if sess.Role == userstore.RoleSuperAdmin {
|
||||
q := r.URL.Query().Get("tenant_id")
|
||||
if q == "" {
|
||||
writeError(w, http.StatusBadRequest, "tenant_id query parameter required for superadmin")
|
||||
return 0, false
|
||||
}
|
||||
tid, err := strconv.ParseInt(q, 10, 64)
|
||||
if err != nil || tid <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid tenant_id")
|
||||
return 0, false
|
||||
}
|
||||
return tid, true
|
||||
}
|
||||
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "no tenant context")
|
||||
return 0, false
|
||||
}
|
||||
return *sess.TenantID, true
|
||||
}
|
||||
|
||||
// availableScanTitleFormats returns the example token patterns the frontend can
|
||||
// offer as clickable starting points. These are suggestions only — any free
|
||||
// token pattern is accepted (see dateformat.Translate) — not a validation
|
||||
// constraint. Returned as a fresh slice so callers never share backing storage.
|
||||
func availableScanTitleFormats() []string {
|
||||
out := make([]string, len(exampleScanTitleFormats))
|
||||
copy(out, exampleScanTitleFormats)
|
||||
return out
|
||||
}
|
||||
|
||||
type tenantSettingsResponse struct {
|
||||
ScanTitleDateFormat string `json:"scan_title_date_format"`
|
||||
ScanTitlePrefix string `json:"scan_title_prefix"`
|
||||
// DefaultTitleTemplate is the tenant-wide fallback title template (Go
|
||||
// text/template) used when an applied classification template carries no
|
||||
// own title_template. Empty string means "no tenant default".
|
||||
DefaultTitleTemplate string `json:"default_title_template"`
|
||||
// AvailableFormats is a list of example patterns (suggestions), not an
|
||||
// allow-list; the field name is kept for frontend compatibility.
|
||||
AvailableFormats []string `json:"available_formats"`
|
||||
}
|
||||
|
||||
// effectiveScanTitlePrefix returns the tenant's stored prefix, or the default
|
||||
// for empty/legacy rows, so the response always reflects what would actually be
|
||||
// used at upload time.
|
||||
func effectiveScanTitlePrefix(stored string) string {
|
||||
if p := strings.TrimSpace(stored); p != "" {
|
||||
return p
|
||||
}
|
||||
return defaultScanTitlePrefix
|
||||
}
|
||||
|
||||
// effectiveScanTitleFormat returns the tenant's stored token pattern, or the
|
||||
// default for empty/invalid rows, so the response always reflects the pattern
|
||||
// that would actually be used at upload time.
|
||||
func effectiveScanTitleFormat(stored string) string {
|
||||
if s := strings.TrimSpace(stored); s != "" {
|
||||
if _, err := dateformat.Translate(s); err == nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return defaultScanTitleDateFormat
|
||||
}
|
||||
|
||||
// handleGetTenantSettings returns the tenant's own settings (currently the
|
||||
// placeholder-title date format) plus a list of example format patterns
|
||||
// (suggestions for the frontend, not an allow-list).
|
||||
func (s *Server) handleGetTenantSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if s.tenantStore == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "tenant store not configured")
|
||||
return
|
||||
}
|
||||
tenantID, ok := s.resolveTenantSettingsTenant(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.tenantStore.GetByID(r.Context(), tenantID)
|
||||
if err != nil || t == nil {
|
||||
writeError(w, http.StatusInternalServerError, "load tenant settings failed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, tenantSettingsResponse{
|
||||
ScanTitleDateFormat: effectiveScanTitleFormat(t.ScanTitleDateFormat),
|
||||
ScanTitlePrefix: effectiveScanTitlePrefix(t.ScanTitlePrefix),
|
||||
DefaultTitleTemplate: t.DefaultTitleTemplate,
|
||||
AvailableFormats: availableScanTitleFormats(),
|
||||
})
|
||||
}
|
||||
|
||||
// updateTenantSettingsRequest uses pointer fields for PATCH-like semantics:
|
||||
// only the fields present in the JSON body are updated, so the two settings
|
||||
// can be changed independently of one another.
|
||||
type updateTenantSettingsRequest struct {
|
||||
ScanTitleDateFormat *string `json:"scan_title_date_format"`
|
||||
ScanTitlePrefix *string `json:"scan_title_prefix"`
|
||||
DefaultTitleTemplate *string `json:"default_title_template"`
|
||||
}
|
||||
|
||||
// handleUpdateTenantSettings persists the tenant's placeholder-title date
|
||||
// format. The submitted value is a free token pattern (e.g. "DD.MM.YYYY HH:mm")
|
||||
// validated via dateformat.Translate; an unparseable pattern yields 400 with
|
||||
// the translator's error message. Every attempt — success or failure — is
|
||||
// audit-logged (GoBD).
|
||||
func (s *Server) handleUpdateTenantSettings(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
|
||||
if s.tenantStore == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "tenant store not configured")
|
||||
return
|
||||
}
|
||||
tenantID, ok := s.resolveTenantSettingsTenant(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
logFail := func(detail string) {
|
||||
tid := tenantID
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventTenantMgmt, Username: sess.Username,
|
||||
TenantID: &tid, Success: false, Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
var req updateTenantSettingsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
logFail("tenant_settings invalid_body")
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.ScanTitleDateFormat == nil && req.ScanTitlePrefix == nil && req.DefaultTitleTemplate == nil {
|
||||
logFail("tenant_settings no_fields")
|
||||
writeError(w, http.StatusBadRequest, "no settings provided")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate everything before persisting anything, so a bad prefix can never
|
||||
// leave a half-applied update.
|
||||
if req.ScanTitleDateFormat != nil {
|
||||
if _, err := dateformat.Translate(*req.ScanTitleDateFormat); err != nil {
|
||||
logFail("tenant_settings invalid_scan_title_date_format:" + *req.ScanTitleDateFormat)
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.ScanTitlePrefix != nil && strings.TrimSpace(*req.ScanTitlePrefix) == "" {
|
||||
logFail("tenant_settings empty_scan_title_prefix")
|
||||
writeError(w, http.StatusBadRequest, "scan_title_prefix must not be empty")
|
||||
return
|
||||
}
|
||||
// An empty default_title_template is allowed (clears the tenant default);
|
||||
// a non-empty one must parse as a valid Go text/template.
|
||||
if req.DefaultTitleTemplate != nil {
|
||||
if err := storage.ValidateTitleTemplate(*req.DefaultTitleTemplate); err != nil {
|
||||
logFail("tenant_settings invalid_default_title_template")
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var detail string
|
||||
if req.ScanTitleDateFormat != nil {
|
||||
if err := s.tenantStore.UpdateScanTitleDateFormat(r.Context(), tenantID, *req.ScanTitleDateFormat); err != nil {
|
||||
logFail("tenant_settings update_failed")
|
||||
writeError(w, http.StatusInternalServerError, "save tenant settings failed")
|
||||
return
|
||||
}
|
||||
detail += " scan_title_date_format:" + *req.ScanTitleDateFormat
|
||||
}
|
||||
if req.ScanTitlePrefix != nil {
|
||||
if err := s.tenantStore.UpdateScanTitlePrefix(r.Context(), tenantID, *req.ScanTitlePrefix); err != nil {
|
||||
// Store-level validation (length) or DB error.
|
||||
logFail("tenant_settings update_failed")
|
||||
writeError(w, http.StatusBadRequest, "invalid scan_title_prefix")
|
||||
return
|
||||
}
|
||||
detail += " scan_title_prefix:" + strings.TrimSpace(*req.ScanTitlePrefix)
|
||||
}
|
||||
if req.DefaultTitleTemplate != nil {
|
||||
if err := s.tenantStore.UpdateDefaultTitleTemplate(r.Context(), tenantID, *req.DefaultTitleTemplate); err != nil {
|
||||
logFail("tenant_settings update_failed")
|
||||
writeError(w, http.StatusBadRequest, "invalid default_title_template")
|
||||
return
|
||||
}
|
||||
detail += " default_title_template:" + strings.TrimSpace(*req.DefaultTitleTemplate)
|
||||
}
|
||||
|
||||
tid := tenantID
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventTenantMgmt, Username: sess.Username,
|
||||
TenantID: &tid, Success: true,
|
||||
Detail: "tenant_settings" + detail,
|
||||
})
|
||||
|
||||
// Reload so the response reflects the effective persisted state for both
|
||||
// fields, regardless of which one this request changed.
|
||||
t, err := s.tenantStore.GetByID(r.Context(), tenantID)
|
||||
if err != nil || t == nil {
|
||||
writeError(w, http.StatusInternalServerError, "load tenant settings failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tenantSettingsResponse{
|
||||
ScanTitleDateFormat: effectiveScanTitleFormat(t.ScanTitleDateFormat),
|
||||
ScanTitlePrefix: effectiveScanTitlePrefix(t.ScanTitlePrefix),
|
||||
DefaultTitleTemplate: t.DefaultTitleTemplate,
|
||||
AvailableFormats: availableScanTitleFormats(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Trash + staged-deletion HTTP handlers (see internal/storage/trash.go):
|
||||
//
|
||||
// GET /api/trash list soft-deleted docs
|
||||
// POST /api/trash/{id}/restore restore from trash
|
||||
// POST /api/trash/{id}/delete-requests request final deletion (User A)
|
||||
// GET /api/trash/{id}/delete-requests request status/history
|
||||
// POST /api/trash/{id}/delete-requests/{reqId}/confirm confirm + execute (User B, domain_admin)
|
||||
// DELETE /api/trash/{id}/delete-requests/{reqId} withdraw a pending request
|
||||
//
|
||||
// The soft-delete itself lives on DELETE /api/documents/{id}
|
||||
// (handleDeleteDocument). Ownership is enforced in the store layer via
|
||||
// id+tenant_id; the two-person rule (requester != confirmer) is enforced in
|
||||
// ConfirmDeleteRequest. Every phase emits its own append-only audit entry.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
// handleListTrash handles GET /api/trash.
|
||||
func (s *Server) handleListTrash(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docs, err := s.store.ListTrash(r.Context(), *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list trash failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, docs)
|
||||
}
|
||||
|
||||
// handleRestoreDocument handles POST /api/trash/{id}/restore.
|
||||
func (s *Server) handleRestoreDocument(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
if err := s.store.RestoreDocument(r.Context(), id, *sess.TenantID, sess.UserID); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
msg := "restore failed"
|
||||
if errors.Is(err, storage.ErrDocumentNotInTrash) {
|
||||
status = http.StatusNotFound
|
||||
msg = "document not found in trash"
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentRestore, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentRestore, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "restored"})
|
||||
}
|
||||
|
||||
// handleListDeleteRequests handles GET /api/trash/{id}/delete-requests.
|
||||
func (s *Server) handleListDeleteRequests(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
reqs, err := s.store.ListDeleteRequests(r.Context(), id, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list delete requests failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, reqs)
|
||||
}
|
||||
|
||||
// handleCreateDeleteRequest handles POST /api/trash/{id}/delete-requests.
|
||||
// User A requests final deletion; retention is re-checked, and a blocked
|
||||
// attempt is still recorded (409).
|
||||
func (s *Server) handleCreateDeleteRequest(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
req, err := s.store.CreateDeleteRequest(r.Context(), id, *sess.TenantID, sess.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrRetentionActive) {
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteBlocked, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "delete request blocked by retention"})
|
||||
writeError(w, http.StatusConflict, "document is under retention and cannot be deleted yet")
|
||||
return
|
||||
}
|
||||
status := http.StatusInternalServerError
|
||||
msg := "create delete request failed"
|
||||
if errors.Is(err, storage.ErrDocumentNotInTrash) {
|
||||
status = http.StatusNotFound
|
||||
msg = "document not found in trash"
|
||||
} else if errors.Is(err, storage.ErrDeleteRequestExists) {
|
||||
status = http.StatusConflict
|
||||
msg = "a pending delete request already exists"
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteRequest, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteRequest, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true, Detail: "request id:" + strconv.FormatInt(req.ID, 10)})
|
||||
writeJSON(w, http.StatusCreated, req)
|
||||
}
|
||||
|
||||
// handleCancelDeleteRequest handles DELETE /api/trash/{id}/delete-requests/{reqId}.
|
||||
func (s *Server) handleCancelDeleteRequest(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
reqID, err2 := strconv.ParseInt(r.PathValue("reqId"), 10, 64)
|
||||
if err != nil || err2 != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.store.CancelDeleteRequest(r.Context(), id, reqID, *sess.TenantID, sess.UserID); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
msg := "cancel delete request failed"
|
||||
if errors.Is(err, storage.ErrDeleteRequestNotFound) {
|
||||
status = http.StatusNotFound
|
||||
msg = "pending delete request not found"
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteRequest, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "cancel req:" + r.PathValue("reqId") + " err:" + err.Error()})
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteRequest, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true, Detail: "cancelled req:" + r.PathValue("reqId")})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"})
|
||||
}
|
||||
|
||||
// handleConfirmDeleteRequest handles
|
||||
// POST /api/trash/{id}/delete-requests/{reqId}/confirm (domain_admin, User B).
|
||||
// The store enforces requester != confirmer and re-checks retention before it
|
||||
// removes the physical WORM file and tombstones the DB row.
|
||||
func (s *Server) handleConfirmDeleteRequest(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
reqID, err2 := strconv.ParseInt(r.PathValue("reqId"), 10, 64)
|
||||
if err != nil || err2 != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
docIDStr := r.PathValue("id")
|
||||
|
||||
exec, err := s.store.ConfirmDeleteRequest(r.Context(), id, reqID, *sess.TenantID, sess.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrRetentionActive) {
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteBlocked, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docIDStr, Success: false, Detail: "confirm blocked by retention req:" + r.PathValue("reqId")})
|
||||
writeError(w, http.StatusConflict, "document is under retention and cannot be deleted yet")
|
||||
return
|
||||
}
|
||||
status := http.StatusInternalServerError
|
||||
msg := "confirm delete request failed"
|
||||
if errors.Is(err, storage.ErrSelfConfirm) {
|
||||
status = http.StatusForbidden
|
||||
msg = "delete request must be confirmed by a different user"
|
||||
} else if errors.Is(err, storage.ErrDeleteRequestNotFound) {
|
||||
status = http.StatusNotFound
|
||||
msg = "pending delete request not found"
|
||||
} else if errors.Is(err, storage.ErrDocumentNotInTrash) {
|
||||
status = http.StatusNotFound
|
||||
msg = "document not found in trash"
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteConfirm, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docIDStr, Success: false, Detail: "req:" + r.PathValue("reqId") + " err:" + err.Error()})
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
|
||||
// Two-person rule: emit a confirm entry (User B) and an execute entry that
|
||||
// records the requester (User A) whose request was finally carried out.
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteConfirm, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docIDStr, Success: true, Detail: "confirmed req:" + strconv.FormatInt(exec.RequestID, 10)})
|
||||
|
||||
// GoBD-taugliches Löschprotokoll (ecoDMS-Muster): the execute entry carries
|
||||
// a structured, self-contained record of the final, irreversible deletion —
|
||||
// who requested it (User A) and when, who confirmed/executed it (User B, the
|
||||
// current session) and when, which document (title + content_hash as the
|
||||
// tamper-evident fingerprint of the removed WORM file), the retention state
|
||||
// at execution time, and the legal basis (Vier-Augen-Prinzip + elapsed/absent
|
||||
// retention). Encoded as JSON in the audit Detail field so the append-only
|
||||
// audit_log (DB + JSON-Lines mirror) remains the single source of truth
|
||||
// without a dedicated table.
|
||||
protokoll := map[string]any{
|
||||
"loeschprotokoll": true,
|
||||
"document_id": docIDStr,
|
||||
"title": exec.Title,
|
||||
"content_hash": exec.ContentHash,
|
||||
"worm_file_removed": exec.StoragePath,
|
||||
"request_id": exec.RequestID,
|
||||
"requested_by_user_id": exec.RequestedBy,
|
||||
"requested_at": exec.RequestedAt.UTC().Format(time.RFC3339),
|
||||
"confirmed_by_user_id": sess.UserID,
|
||||
"confirmed_by_username": sess.Username,
|
||||
"executed_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"rechtsgrundlage": "Vier-Augen-Prinzip erfuellt; Aufbewahrungsfrist (retain_until) abgelaufen oder nicht gesetzt",
|
||||
}
|
||||
if exec.RetainUntil != nil {
|
||||
protokoll["retain_until"] = exec.RetainUntil.UTC().Format(time.RFC3339)
|
||||
} else {
|
||||
protokoll["retain_until"] = nil
|
||||
}
|
||||
detail := "executed req:" + strconv.FormatInt(exec.RequestID, 10) +
|
||||
" requested_by_user_id:" + strconv.FormatInt(exec.RequestedBy, 10) +
|
||||
" removed:" + exec.StoragePath
|
||||
if b, jerr := json.Marshal(protokoll); jerr == nil {
|
||||
detail = string(b)
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteExecute, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docIDStr, Success: true, Detail: detail})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "executed"})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/userstore"
|
||||
)
|
||||
|
||||
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
var users []*userstore.User
|
||||
var err error
|
||||
if sess.TenantID != nil {
|
||||
users, err = s.users.ListByTenant(r.Context(), *sess.TenantID)
|
||||
} else {
|
||||
users, err = s.users.List("")
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list users failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
// TenantID is only ever evaluated for a superadmin caller (see
|
||||
// handleCreateUser). For any other role it is silently ignored and the
|
||||
// caller's own sess.TenantID is enforced instead — this is a deliberate
|
||||
// IDOR guard: a domain_admin must never be able to steer a created user
|
||||
// into a tenant other than their own by sending a different tenant_id.
|
||||
TenantID *int64 `json:"tenant_id"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
var req createUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Role == "" {
|
||||
req.Role = userstore.RoleUser
|
||||
}
|
||||
|
||||
// Tenant assignment is rollenabhängig (security-critical, see plan):
|
||||
// - superadmin: may set tenant_id explicitly from the request body,
|
||||
// including nil for another tenant-less superadmin.
|
||||
// - everyone else (domain_admin, user): tenant_id is ALWAYS hard-forced
|
||||
// to the caller's own sess.TenantID; any tenant_id in the request
|
||||
// body is completely ignored, not merely validated, to close the
|
||||
// IDOR hole where a domain_admin could otherwise create a user in a
|
||||
// tenant they don't administer.
|
||||
tenantID := sess.TenantID
|
||||
if sess.Role == userstore.RoleSuperAdmin {
|
||||
tenantID = req.TenantID
|
||||
}
|
||||
|
||||
user, err := s.users.Create(userstore.CreateUserRequest{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Role: req.Role,
|
||||
TenantID: tenantID,
|
||||
})
|
||||
if err != nil {
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventUserMgmt, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "create_user_failed"})
|
||||
writeError(w, http.StatusBadRequest, "create user failed")
|
||||
return
|
||||
}
|
||||
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventUserMgmt, Username: sess.Username, TenantID: sess.TenantID, Success: true, Detail: "user_created:" + user.Username})
|
||||
writeJSON(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
type updateUserRequest struct {
|
||||
Email *string `json:"email"`
|
||||
Role *string `json:"role"`
|
||||
Active *bool `json:"active"`
|
||||
Password *string `json:"password"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
var req updateUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.users.Update(id, userstore.UpdateUserRequest{
|
||||
Email: req.Email, Role: req.Role, Active: req.Active, Password: req.Password,
|
||||
})
|
||||
if err != nil {
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventUserMgmt, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "update_user_failed"})
|
||||
writeError(w, http.StatusBadRequest, "update user failed")
|
||||
return
|
||||
}
|
||||
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventUserMgmt, Username: sess.Username, TenantID: sess.TenantID, Success: true, Detail: "user_updated:" + user.Username})
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
if err := s.users.Delete(id); err != nil {
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventUserMgmt, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "delete_user_failed"})
|
||||
writeError(w, http.StatusBadRequest, "delete user failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventUserMgmt, Username: sess.Username, TenantID: sess.TenantID, Success: true, Detail: "user_deleted"})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
// Workflow ("Consumption-Regeln") HTTP handlers (see
|
||||
// internal/storage/workflows.go):
|
||||
//
|
||||
// GET/POST /api/workflows GET/PUT/DELETE /api/workflows/{id}
|
||||
// PUT /api/workflows/{id}/actions
|
||||
// POST /api/workflows/{id}/test
|
||||
// GET /api/workflows/{id}/runs
|
||||
//
|
||||
// Workflow administration (CRUD + action bulk replace) requires domain_admin
|
||||
// (s.authAdmin, enforced in server.go). The dry-run test and the runs overview
|
||||
// are normal authenticated tenant actions (s.auth). Ownership is enforced in
|
||||
// the store layer (id+tenant_id). Every mutation is audit-logged, incl. failures.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
type workflowRequest struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
ConditionTree json.RawMessage `json:"condition_tree"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
// handleListWorkflows handles GET /api/workflows (without actions).
|
||||
func (s *Server) handleListWorkflows(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
workflows, err := s.store.ListWorkflows(r.Context(), *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list workflows failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, workflows)
|
||||
}
|
||||
|
||||
// handleGetWorkflow handles GET /api/workflows/{id} (resolved with actions).
|
||||
func (s *Server) handleGetWorkflow(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
wf, err := s.store.GetWorkflow(r.Context(), id, *sess.TenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrWorkflowNotFound) {
|
||||
writeError(w, http.StatusNotFound, "workflow not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "get workflow failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, wf)
|
||||
}
|
||||
|
||||
// handleCreateWorkflow handles POST /api/workflows (domain_admin+).
|
||||
func (s *Server) handleCreateWorkflow(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
var req workflowRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
wf, err := s.store.CreateWorkflow(r.Context(), *sess.TenantID, storage.CreateWorkflowRequest{
|
||||
Name: req.Name, Enabled: req.Enabled, TriggerType: req.TriggerType,
|
||||
ConditionTree: req.ConditionTree, Priority: req.Priority, CreatedBy: &sess.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
status := workflowErrorStatus(err)
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventWorkflowCreate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "workflow_create err:" + err.Error()})
|
||||
writeError(w, status, workflowErrorMessage(err, "create workflow failed"))
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventWorkflowCreate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "workflow_create id:" + strconv.FormatInt(wf.ID, 10) + " name:" + wf.Name,
|
||||
})
|
||||
writeJSON(w, http.StatusCreated, wf)
|
||||
}
|
||||
|
||||
// handleUpdateWorkflow handles PUT /api/workflows/{id} (domain_admin+).
|
||||
func (s *Server) handleUpdateWorkflow(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var req workflowRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
err = s.store.UpdateWorkflow(r.Context(), id, *sess.TenantID, storage.UpdateWorkflowRequest{
|
||||
Name: req.Name, Enabled: req.Enabled, TriggerType: req.TriggerType,
|
||||
ConditionTree: req.ConditionTree, Priority: req.Priority,
|
||||
})
|
||||
if err != nil {
|
||||
status := workflowErrorStatus(err)
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventWorkflowUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "workflow_update id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
|
||||
writeError(w, status, workflowErrorMessage(err, "update workflow failed"))
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventWorkflowUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "workflow_update id:" + strconv.FormatInt(id, 10),
|
||||
})
|
||||
wf, err := s.store.GetWorkflow(r.Context(), id, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "reload workflow failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, wf)
|
||||
}
|
||||
|
||||
// handleDeleteWorkflow handles DELETE /api/workflows/{id} (domain_admin+).
|
||||
func (s *Server) handleDeleteWorkflow(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteWorkflow(r.Context(), id, *sess.TenantID); err != nil {
|
||||
status := http.StatusNotFound
|
||||
if !errors.Is(err, storage.ErrWorkflowNotFound) {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventWorkflowDelete, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "workflow_delete id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
|
||||
writeError(w, status, "delete workflow failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventWorkflowDelete, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "workflow_delete id:" + strconv.FormatInt(id, 10),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
type workflowActionsRequest struct {
|
||||
Actions []struct {
|
||||
ActionType string `json:"action_type"`
|
||||
ActionConfig json.RawMessage `json:"action_config"`
|
||||
} `json:"actions"`
|
||||
}
|
||||
|
||||
// handleSetWorkflowActions handles PUT /api/workflows/{id}/actions (bulk
|
||||
// replace, domain_admin+).
|
||||
func (s *Server) handleSetWorkflowActions(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var req workflowActionsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
actions := make([]storage.WorkflowActionInput, 0, len(req.Actions))
|
||||
for _, a := range req.Actions {
|
||||
actions = append(actions, storage.WorkflowActionInput{ActionType: a.ActionType, ActionConfig: a.ActionConfig})
|
||||
}
|
||||
if err := s.store.SetWorkflowActions(r.Context(), id, *sess.TenantID, actions); err != nil {
|
||||
status := workflowErrorStatus(err)
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventWorkflowUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "workflow_actions_set id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
|
||||
writeError(w, status, workflowErrorMessage(err, "set workflow actions failed"))
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventWorkflowUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "workflow_actions_set id:" + strconv.FormatInt(id, 10) + " count:" + strconv.Itoa(len(actions)),
|
||||
})
|
||||
wf, err := s.store.GetWorkflow(r.Context(), id, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "reload workflow failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, wf)
|
||||
}
|
||||
|
||||
type workflowTestRequest struct {
|
||||
DocumentID *int64 `json:"document_id"`
|
||||
RawText string `json:"raw_text"`
|
||||
}
|
||||
|
||||
// handleTestWorkflow handles POST /api/workflows/{id}/test. Pure dry-run: it
|
||||
// reports which leaves matched and which actions WOULD run, never executing
|
||||
// them. Evaluated against an existing document (document_id) or a synthetic
|
||||
// document built from raw_text.
|
||||
func (s *Server) handleTestWorkflow(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var req workflowTestRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
res, err := s.store.TestWorkflow(r.Context(), id, *sess.TenantID, req.DocumentID, req.RawText)
|
||||
if err != nil {
|
||||
status := workflowErrorStatus(err)
|
||||
if errors.Is(err, storage.ErrDocumentNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventWorkflowRun, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "workflow_test id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
|
||||
writeError(w, status, workflowErrorMessage(err, "test workflow failed"))
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventWorkflowRun, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "workflow_test id:" + strconv.FormatInt(id, 10) + " matched:" + strconv.FormatBool(res.Matched),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// handleListWorkflowRuns handles GET /api/workflows/{id}/runs (optional
|
||||
// ?limit=).
|
||||
func (s *Server) handleListWorkflowRuns(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
limit := 0
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
runs, err := s.store.ListWorkflowRuns(r.Context(), id, *sess.TenantID, limit)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrWorkflowNotFound) {
|
||||
writeError(w, http.StatusNotFound, "workflow not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "list workflow runs failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, runs)
|
||||
}
|
||||
|
||||
// workflowErrorStatus maps store errors from the workflow write path to HTTP
|
||||
// status codes.
|
||||
func workflowErrorStatus(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrWorkflowNotFound):
|
||||
return http.StatusNotFound
|
||||
case errors.Is(err, storage.ErrDuplicateWorkflowName):
|
||||
return http.StatusConflict
|
||||
case errors.Is(err, storage.ErrInvalidConditionTree), errors.Is(err, storage.ErrInvalidWorkflowAction):
|
||||
return http.StatusBadRequest
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
// workflowErrorMessage returns the store error's message for the client on the
|
||||
// validation cases (safe, user-actionable), otherwise the generic fallback.
|
||||
func workflowErrorMessage(err error, fallback string) string {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrInvalidConditionTree), errors.Is(err, storage.ErrInvalidWorkflowAction):
|
||||
return err.Error()
|
||||
case errors.Is(err, storage.ErrWorkflowNotFound):
|
||||
return "workflow not found"
|
||||
case errors.Is(err, storage.ErrDuplicateWorkflowName):
|
||||
return "workflow with this name already exists"
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
// Package audit is a PostgreSQL-backed, append-only audit log, ported 1:1
|
||||
// from archivmail's internal/audit pattern (including the DB-level
|
||||
// immutability trigger and the tamper-evident JSON-Lines mirror file), with
|
||||
// the mail-specific fields (mail_id, query) replaced by a generic document_id
|
||||
// so the log fits archivdms's document-centric core model.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Event type constants.
|
||||
const (
|
||||
EventLogin = "login"
|
||||
EventLogout = "logout"
|
||||
EventUserMgmt = "user_mgmt"
|
||||
EventTenantMgmt = "tenant_mgmt"
|
||||
|
||||
// EventDocumentCreate/Update/Delete cover document lifecycle changes.
|
||||
EventDocumentCreate = "document_create"
|
||||
EventDocumentUpdate = "document_update"
|
||||
EventDocumentDelete = "document_delete"
|
||||
|
||||
// EventDocumentReprocessed records a re-processing run on an already-archived
|
||||
// document (POST /api/documents/{id}/reprocess): OCR is re-run on the stored
|
||||
// WORM file and ocr_text refreshed, followed by best-effort auto-assignment
|
||||
// and on_upload workflows. The file itself stays untouched (WORM).
|
||||
EventDocumentReprocessed = "document_reprocessed"
|
||||
|
||||
// EventDocumentProcessed records the asynchronous post-upload processing
|
||||
// run of the tenant job queue (internal/jobqueue -> api.ProcessDocumentJob):
|
||||
// OCR extraction, title/belegdatum derivation, taxonomy auto-assignment and
|
||||
// on_upload workflows on an already-staged, already-archived document.
|
||||
// Logged on success AND failure (retry/backoff attempts included) so the
|
||||
// GoBD trail covers the whole ingest chain, not just the synchronous
|
||||
// staging step (EventDocumentCreate). The WORM file is never touched.
|
||||
EventDocumentProcessed = "document_processed"
|
||||
|
||||
// Manual taxonomy assignment on a single document (doc_type/correspondent).
|
||||
// Auto-assignment during upload is covered by EventDocumentCreate.
|
||||
EventDocTypeSet = "document_doctype_set"
|
||||
EventCorrespondentSet = "document_correspondent_set"
|
||||
|
||||
// Trash / staged deletion workflow (Papierkorb + Vier-Augen-Prinzip).
|
||||
// Each phase produces its own append-only entry; confirm/execute emit
|
||||
// separate entries for requester (User A) and confirmer (User B).
|
||||
EventDocumentTrash = "document_trash" // soft-delete into trash
|
||||
EventDocumentRestore = "document_restore" // restored from trash
|
||||
EventDocumentDeleteRequest = "document_delete_request" // final-deletion requested (User A)
|
||||
EventDocumentDeleteConfirm = "document_delete_confirm" // final-deletion confirmed (User B)
|
||||
EventDocumentDeleteExecute = "document_delete_execute" // WORM file removed, tombstone kept
|
||||
EventDocumentDeleteBlocked = "document_delete_blocked_retention" // blocked by retain_until
|
||||
|
||||
// Reminder ("Wiedervorlage") events.
|
||||
EventReminderCreate = "reminder_create"
|
||||
EventReminderStatusChange = "reminder_status_change"
|
||||
EventReminderDelete = "reminder_delete"
|
||||
EventReminderNotify = "reminder_notify" // cron: due-date notification sent
|
||||
|
||||
// SFTP credential lifecycle + login events (internal/sftpserver,
|
||||
// internal/api/sftp_handlers.go).
|
||||
EventSFTPCredentialCreate = "sftp_credential_create"
|
||||
EventSFTPCredentialRevoke = "sftp_credential_revoke"
|
||||
EventSFTPLogin = "sftp_login" // logged on every attempt, success and failure
|
||||
|
||||
// Permission model (group-resolved document ACL, internal/storage/permissions.go,
|
||||
// internal/api/permission_handlers.go). Covers group/member changes and all
|
||||
// three grant layers (document-type / tag / per-document, incl. 'deny').
|
||||
EventPermissionGrantChanged = "permission_grant_changed"
|
||||
|
||||
// External document share-links (internal/storage/shares.go,
|
||||
// internal/api/share_handlers.go + public_share_handlers.go). Create/Revoke
|
||||
// are authenticated tenant actions; Accessed is logged on the public
|
||||
// download endpoint (success and failure — see also the per-attempt
|
||||
// document_share_accesses table for the full public-access trail).
|
||||
EventShareCreated = "share_created"
|
||||
EventShareRevoked = "share_revoked"
|
||||
EventShareAccessed = "share_accessed"
|
||||
|
||||
// LDAP directory integration (internal/ldapstore, internal/ldapauth,
|
||||
// internal/api/ldap_handlers.go). ConfigChanged covers create/update/delete
|
||||
// and the test action; LoginSuccess/Failed are logged on every LDAP bind
|
||||
// attempt (incl. JIT provisioning); RoleSync records a role change applied by
|
||||
// group membership re-synchronisation (including downgrades).
|
||||
EventLdapConfigChanged = "ldap_config_changed"
|
||||
EventLdapLoginSuccess = "ldap_login_success"
|
||||
EventLdapLoginFailed = "ldap_login_failed"
|
||||
EventLdapRoleSync = "ldap_role_sync"
|
||||
|
||||
// EventOllamaConfigUpdate records a change to a tenant's external-Ollama
|
||||
// connection config (GET/PUT /api/ollama-config). Logged on every attempt,
|
||||
// success and failure (GoBD-Nachvollziehbarkeit).
|
||||
EventOllamaConfigUpdate = "ollama_config_update"
|
||||
|
||||
// Classification templates (Klassifizierungsvorlagen,
|
||||
// internal/storage/classification_templates.go,
|
||||
// internal/api/classification_template_handlers.go). Create/Update/Delete
|
||||
// cover template administration (incl. tag / field-default bulk replace);
|
||||
// Applied records applying a template to a document — logged on success and
|
||||
// failure, and also when a retain_until shortening attempt was rejected
|
||||
// (RetainUntilBlocked) so GoBD traceability shows the rejection explicitly.
|
||||
EventTemplateCreate = "classification_template_create"
|
||||
EventTemplateUpdate = "classification_template_update"
|
||||
EventTemplateDelete = "classification_template_delete"
|
||||
EventTemplateApplied = "classification_template_applied"
|
||||
|
||||
// Workflows / Consumption-Regeln (internal/storage/workflows.go,
|
||||
// internal/api/workflow_handlers.go). Create/Update/Delete cover workflow
|
||||
// administration (incl. action bulk replace and dry-run test). EventWorkflowRun
|
||||
// records a manual (test-endpoint) or automatic (on_upload) evaluation. Note
|
||||
// automatic runs are additionally recorded document-scoped in the
|
||||
// workflow_runs table for GoBD reproducibility.
|
||||
EventWorkflowCreate = "workflow_create"
|
||||
EventWorkflowUpdate = "workflow_update"
|
||||
EventWorkflowDelete = "workflow_delete"
|
||||
EventWorkflowRun = "workflow_run"
|
||||
|
||||
// Heuristische Metadaten-Vorschläge (internal/storage/metadata_suggestions.go,
|
||||
// internal/api/metadata_suggestion_handlers.go). Records a (non-binding)
|
||||
// suggestion run for a document; accepting a suggested field goes through the
|
||||
// normal edit endpoints, not through this event.
|
||||
EventSuggestionGenerated = "metadata_suggestion_generated"
|
||||
|
||||
// Freitext-Notizen pro Dokument (internal/storage/document_notes.go,
|
||||
// internal/api/document_note_handlers.go). Create/Delete cover the note
|
||||
// lifecycle; pure reads (listing notes) are not audited, consistent with the
|
||||
// rest of this project. Notes are hard-deleted (not GoBD documents), but the
|
||||
// deletion itself is still recorded for Nachvollziehbarkeit.
|
||||
EventNoteCreate = "document_note_create"
|
||||
EventNoteDelete = "document_note_delete"
|
||||
|
||||
// EventMLRetrain records a Naive-Bayes classifier retraining run
|
||||
// (internal/classifier, cmd/archivdms/cmd_classify_retrain.go, cron-driven).
|
||||
// Logged once per tenant per retrain, success and failure — a per-tenant
|
||||
// failure is isolated and does not block the other tenants. Detail carries
|
||||
// the per-kind document counts / skip reasons for GoBD-Nachvollziehbarkeit.
|
||||
EventMLRetrain = "ml_classifier_retrain"
|
||||
|
||||
// Gespeicherte Suchansichten (SavedViews, Paperless-ngx inspiriert —
|
||||
// internal/storage/saved_views.go, internal/api/saved_view_handlers.go).
|
||||
// Create/Update/Delete cover a user's named, reusable search/filter view.
|
||||
// Views can be private (own) or shared tenant-wide (is_shared); only the
|
||||
// creator may update or delete a view. Pure reads (listing views) are not
|
||||
// audited, consistent with the rest of this project.
|
||||
EventSavedViewCreate = "saved_view_create"
|
||||
EventSavedViewUpdate = "saved_view_update"
|
||||
EventSavedViewDelete = "saved_view_delete"
|
||||
|
||||
// EventRetentionApplied records a batch run of the GoBD retention-rules
|
||||
// engine (internal/storage/retention_rules.go ApplyRetentionRules,
|
||||
// cmd/archivdms/cmd_retention_apply.go, cron-driven). One summary entry per
|
||||
// run per tenant (tenant + count of documents whose retain_until was
|
||||
// computed and set), NOT per document — batch-summary style to avoid audit
|
||||
// log spam. Logged on success and failure.
|
||||
EventRetentionApplied = "retention_applied"
|
||||
|
||||
// EventRetentionRuleCreate/Update/Delete record CRUD changes to GoBD
|
||||
// retention rules (internal/storage/retention_rules.go,
|
||||
// internal/api/retention_rule_handlers.go). Compliance-critical: changing a
|
||||
// retention period alters how long documents must be kept, so every mutation
|
||||
// — success and failure — is audit-logged.
|
||||
EventRetentionRuleCreate = "retention_rule_create"
|
||||
EventRetentionRuleUpdate = "retention_rule_update"
|
||||
EventRetentionRuleDelete = "retention_rule_delete"
|
||||
|
||||
// Digitale Akten (digitaler Aktenordner — internal/storage/akten.go,
|
||||
// internal/api/akte_handlers.go). Create/Update/Close/Delete cover the akte
|
||||
// lifecycle; DocumentAdd/DocumentRemove record assigning/removing a document
|
||||
// to/from an akte (PUT /api/documents/{id}/akte). An akte has no own ACL —
|
||||
// its visibility derives from the documents it contains (see
|
||||
// project_akte_konzept_plan.md).
|
||||
EventAkteCreate = "akte_create"
|
||||
EventAkteUpdate = "akte_update"
|
||||
EventAkteClose = "akte_close"
|
||||
EventAkteDelete = "akte_delete"
|
||||
EventAkteDocumentAdd = "akte_document_add"
|
||||
EventAkteDocumentRemove = "akte_document_remove"
|
||||
|
||||
// EventDocumentSplit records a barcode-separator-page split at ingest
|
||||
// (internal/pagesplit, internal/api/document_handlers.go storeUploadedFile).
|
||||
// GoBD-Nachvollziehbarkeit: the uploaded multi-page original is NOT archived
|
||||
// as such — it is replaced by N part documents — so the split itself is the
|
||||
// only record tying the parts back to the original upload. Detail therefore
|
||||
// carries the original filename, its SHA-256, the page count, the separator
|
||||
// page numbers and the resulting document IDs. Logged on success and on
|
||||
// failure (Success:false when the split was attempted but aborted, in which
|
||||
// case the original is archived unsplit).
|
||||
EventDocumentSplit = "document_split"
|
||||
|
||||
// EventDocumentExport records a single-document export
|
||||
// (GET /api/documents/{id}/export): a ZIP containing the original WORM file,
|
||||
// metadata.json and, if present, ocr_text.txt. Read-only, but archived
|
||||
// content plus its full metadata leaves the system as a package, so — like
|
||||
// EventComplianceExport / EventAccountingPull — it is logged like a mutation,
|
||||
// including failures (Success:false).
|
||||
EventDocumentExport = "document_export"
|
||||
|
||||
// EventDocumentBulkExport records a multi-document export
|
||||
// (POST /api/documents/export): one ZIP with a doc-<id>/ folder per
|
||||
// document plus index.csv. Exactly ONE entry per request (not per
|
||||
// document) — Detail carries "exported=<n> skipped=<m>", where skipped
|
||||
// counts documents the caller could not see or that failed to read (also
|
||||
// listed in the archive's errors.txt). Logged on failure too
|
||||
// (Success:false), including rejected requests (too many IDs, invalid
|
||||
// filter) and aborted streams.
|
||||
EventDocumentBulkExport = "document_bulk_export"
|
||||
)
|
||||
|
||||
// Compliance-Export (internal/api/compliance_handlers.go).
|
||||
const (
|
||||
// EventComplianceExport records a generated GoBD-Verfahrensdokumentation
|
||||
// draft. Read-only, but exported tenant configuration leaves the system, so
|
||||
// it is logged like a mutation (including failures) — and for a superadmin
|
||||
// cross-tenant export the Detail carries the target tenant_id.
|
||||
EventComplianceExport = "compliance_procedure_doc_export"
|
||||
)
|
||||
|
||||
// Buchhaltungs-Pull-API (internal/api/accounting_handlers.go,
|
||||
// internal/storage/accounting_api_keys.go).
|
||||
const (
|
||||
// EventAccountingKeyCreated/Revoked record the lifecycle of a per-tenant
|
||||
// accounting API key. The plaintext key is NEVER part of Detail — only the
|
||||
// key id and its label.
|
||||
EventAccountingKeyCreated = "accounting_key_created"
|
||||
EventAccountingKeyRevoked = "accounting_key_revoked"
|
||||
|
||||
// EventAccountingPull records machine-to-machine reads through an
|
||||
// accounting API key. Read-only, but archived content leaves the system via
|
||||
// a non-browser path, so it is logged like a mutation (including failures):
|
||||
// every file download individually, and every list query with its result
|
||||
// range (first/last document id).
|
||||
EventAccountingPull = "accounting_pull"
|
||||
)
|
||||
|
||||
// Entry is a single audit log record.
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
EventType string `json:"event_type"`
|
||||
Username string `json:"username"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
DocumentID string `json:"document_id"`
|
||||
Success bool `json:"success"`
|
||||
Detail string `json:"detail"`
|
||||
// TenantID, when set, records which tenant this event belongs to. nil means
|
||||
// a tenant-less / system-wide event (e.g. superadmin actions, cron jobs).
|
||||
TenantID *int64 `json:"tenant_id,omitempty"`
|
||||
}
|
||||
|
||||
// QueryFilter specifies filtering options for audit log queries.
|
||||
type QueryFilter struct {
|
||||
Username string
|
||||
EventType string
|
||||
DocumentID string
|
||||
From *time.Time
|
||||
To *time.Time
|
||||
TenantID *int64
|
||||
PageSize int
|
||||
Page int
|
||||
}
|
||||
|
||||
// Logger is a PostgreSQL-backed, append-only audit log mirrored to a
|
||||
// tamper-evident JSON-Lines file opened in append-only mode.
|
||||
type Logger struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
|
||||
fileMu sync.Mutex
|
||||
file *os.File
|
||||
logPath string
|
||||
}
|
||||
|
||||
type fileEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
EventType string `json:"event_type"`
|
||||
Username string `json:"username"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
DocumentID string `json:"document_id,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
TenantID *int64 `json:"tenant_id,omitempty"`
|
||||
}
|
||||
|
||||
// New connects to PostgreSQL using the given DSN and initialises the schema.
|
||||
func New(dsn, logPath string, logger *slog.Logger) (*Logger, error) {
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit: connect: %w", err)
|
||||
}
|
||||
if err := initSchema(ctx, pool); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("audit: create schema: %w", err)
|
||||
}
|
||||
l := &Logger{pool: pool, logger: logger, logPath: logPath}
|
||||
l.openLogFile()
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// initSchema creates the audit_log table and installs the immutability
|
||||
// trigger. Both operations are idempotent and safe on existing databases.
|
||||
func initSchema(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
username VARCHAR(255) NOT NULL DEFAULT '',
|
||||
ip_address VARCHAR(45) NOT NULL DEFAULT '',
|
||||
document_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
success BOOLEAN NOT NULL DEFAULT true,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
tenant_id BIGINT
|
||||
);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Append-only enforcement at the database level: any UPDATE/DELETE raises.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE OR REPLACE FUNCTION audit_log_no_mutation()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'audit_log is append-only: % is not permitted', TG_OP
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
`); err != nil {
|
||||
return fmt.Errorf("create trigger function: %w", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `DROP TRIGGER IF EXISTS audit_log_immutable ON audit_log;`); err != nil {
|
||||
return fmt.Errorf("drop trigger: %w", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE TRIGGER audit_log_immutable
|
||||
BEFORE UPDATE OR DELETE ON audit_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_log_no_mutation();
|
||||
`); err != nil {
|
||||
return fmt.Errorf("create trigger: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Logger) openLogFile() {
|
||||
if l.logPath == "" {
|
||||
l.logger.Warn("audit: log_path not configured, file logging disabled")
|
||||
return
|
||||
}
|
||||
if dir := filepath.Dir(l.logPath); dir != "" && dir != "." {
|
||||
_ = os.MkdirAll(dir, 0o750)
|
||||
}
|
||||
f, err := os.OpenFile(l.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
|
||||
if err != nil {
|
||||
l.logger.Warn("audit: audit log file not writable, continuing with DB-only logging",
|
||||
"path", l.logPath, "err", err)
|
||||
return
|
||||
}
|
||||
l.file = f
|
||||
}
|
||||
|
||||
// Log appends an entry to the audit log. Errors are logged but not returned.
|
||||
func (l *Logger) Log(entry Entry) {
|
||||
ts := entry.Timestamp
|
||||
if ts.IsZero() {
|
||||
ts = time.Now().UTC()
|
||||
}
|
||||
ctx := context.Background()
|
||||
_, err := l.pool.Exec(ctx,
|
||||
`INSERT INTO audit_log (timestamp, event_type, username, ip_address, document_id, success, detail, tenant_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
ts.UTC(), entry.EventType, entry.Username, entry.IPAddress, entry.DocumentID,
|
||||
entry.Success, entry.Detail, entry.TenantID,
|
||||
)
|
||||
if err != nil {
|
||||
l.logger.Error("audit: insert failed", "err", err)
|
||||
}
|
||||
l.writeFile(entry, ts.UTC())
|
||||
}
|
||||
|
||||
func (l *Logger) writeFile(entry Entry, ts time.Time) {
|
||||
l.fileMu.Lock()
|
||||
defer l.fileMu.Unlock()
|
||||
if l.file == nil {
|
||||
return
|
||||
}
|
||||
line, err := json.Marshal(fileEntry{
|
||||
Timestamp: ts.Format(time.RFC3339),
|
||||
EventType: entry.EventType,
|
||||
Username: entry.Username,
|
||||
IPAddress: entry.IPAddress,
|
||||
DocumentID: entry.DocumentID,
|
||||
Success: entry.Success,
|
||||
Detail: entry.Detail,
|
||||
TenantID: entry.TenantID,
|
||||
})
|
||||
if err != nil {
|
||||
l.logger.Error("audit: marshal log line failed", "err", err)
|
||||
return
|
||||
}
|
||||
if _, err := l.file.Write(append(line, '\n')); err != nil {
|
||||
l.logger.Error("audit: write to log file failed", "path", l.logPath, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Query retrieves audit entries matching the given filter.
|
||||
func (l *Logger) Query(filter QueryFilter) ([]Entry, int, error) {
|
||||
pageSize := filter.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
where, args := buildWhere(filter)
|
||||
ctx := context.Background()
|
||||
|
||||
countSQL := "SELECT COUNT(*) FROM audit_log" + where
|
||||
var total int
|
||||
if err := l.pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("audit: count: %w", err)
|
||||
}
|
||||
|
||||
offset := filter.Page * pageSize
|
||||
limitArg := len(args) + 1
|
||||
offsetArg := len(args) + 2
|
||||
querySQL := fmt.Sprintf(
|
||||
"SELECT id, timestamp, event_type, username, ip_address, document_id, success, detail FROM audit_log%s ORDER BY timestamp DESC LIMIT $%d OFFSET $%d",
|
||||
where, limitArg, offsetArg,
|
||||
)
|
||||
allArgs := append(args, pageSize, offset)
|
||||
|
||||
rows, err := l.pool.Query(ctx, querySQL, allArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("audit: query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []Entry
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
if err := rows.Scan(&e.ID, &e.Timestamp, &e.EventType, &e.Username, &e.IPAddress, &e.DocumentID, &e.Success, &e.Detail); err != nil {
|
||||
return nil, 0, fmt.Errorf("audit: scan: %w", err)
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, total, rows.Err()
|
||||
}
|
||||
|
||||
// Close closes the audit log file and the connection pool.
|
||||
func (l *Logger) Close() error {
|
||||
l.fileMu.Lock()
|
||||
if l.file != nil {
|
||||
_ = l.file.Sync()
|
||||
_ = l.file.Close()
|
||||
l.file = nil
|
||||
}
|
||||
l.fileMu.Unlock()
|
||||
l.pool.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildWhere(f QueryFilter) (string, []interface{}) {
|
||||
var clauses []string
|
||||
var args []interface{}
|
||||
n := 1
|
||||
if f.Username != "" {
|
||||
clauses = append(clauses, fmt.Sprintf("username = $%d", n))
|
||||
args = append(args, f.Username)
|
||||
n++
|
||||
}
|
||||
if f.EventType != "" {
|
||||
clauses = append(clauses, fmt.Sprintf("event_type = $%d", n))
|
||||
args = append(args, f.EventType)
|
||||
n++
|
||||
}
|
||||
if f.DocumentID != "" {
|
||||
clauses = append(clauses, fmt.Sprintf("document_id = $%d", n))
|
||||
args = append(args, f.DocumentID)
|
||||
n++
|
||||
}
|
||||
if f.From != nil {
|
||||
clauses = append(clauses, fmt.Sprintf("timestamp >= $%d", n))
|
||||
args = append(args, f.From.UTC())
|
||||
n++
|
||||
}
|
||||
if f.To != nil {
|
||||
clauses = append(clauses, fmt.Sprintf("timestamp <= $%d", n))
|
||||
args = append(args, f.To.UTC())
|
||||
n++
|
||||
}
|
||||
if f.TenantID != nil {
|
||||
clauses = append(clauses, fmt.Sprintf("tenant_id = $%d", n))
|
||||
args = append(args, *f.TenantID)
|
||||
n++
|
||||
}
|
||||
if len(clauses) == 0 {
|
||||
return "", args
|
||||
}
|
||||
return " WHERE " + strings.Join(clauses, " AND "), args
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
// Package auth implements login, JWT session issuance/validation, and logout,
|
||||
// ported from archivmail's internal/auth pattern. LDAP and TOTP are
|
||||
// intentionally left out of this initial scaffold; they can be re-added later
|
||||
// following the same shape archivmail uses (Manager.SetTenantLDAP etc.).
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/ldapauth"
|
||||
"archivdms/internal/ldapstore"
|
||||
"archivdms/internal/tenantstore"
|
||||
"archivdms/internal/userstore"
|
||||
)
|
||||
|
||||
// Session holds the claims extracted from a validated JWT.
|
||||
type Session struct {
|
||||
UserID int64
|
||||
Username string
|
||||
Email string
|
||||
Role string
|
||||
JTI string
|
||||
TenantID *int64
|
||||
}
|
||||
|
||||
// Manager handles login, token issuance, validation, and logout.
|
||||
type Manager struct {
|
||||
store *userstore.Store
|
||||
jwtSecret []byte
|
||||
|
||||
// ldap is nil until SetLDAP wires the directory integration. When nil the
|
||||
// login flow is local-only (bcrypt).
|
||||
ldap *ldapComponent
|
||||
}
|
||||
|
||||
// ldapComponent bundles the dependencies for LDAP authentication. Kept behind
|
||||
// a pointer so a deployment without LDAP configured pays zero cost.
|
||||
type ldapComponent struct {
|
||||
store *ldapstore.Store
|
||||
authn *ldapauth.Authenticator
|
||||
tenants *tenantstore.Store
|
||||
audlog *audit.Logger
|
||||
limiter *keyedRateLimiter
|
||||
}
|
||||
|
||||
// New creates a new auth Manager.
|
||||
func New(store *userstore.Store, jwtSecret string) *Manager {
|
||||
return &Manager{store: store, jwtSecret: []byte(jwtSecret)}
|
||||
}
|
||||
|
||||
// SetLDAP wires the LDAP directory integration into the auth manager. Called
|
||||
// once at startup after the ldapstore/tenantstore/audit dependencies exist.
|
||||
func (m *Manager) SetLDAP(ldapSt *ldapstore.Store, authn *ldapauth.Authenticator, tenants *tenantstore.Store, audlog *audit.Logger) {
|
||||
m.ldap = &ldapComponent{
|
||||
store: ldapSt,
|
||||
authn: authn,
|
||||
tenants: tenants,
|
||||
audlog: audlog,
|
||||
// 10 attempts burst, refilled at 1 / 6s per key (~10/min sustained).
|
||||
limiter: newKeyedRateLimiter(10, 1.0/6.0),
|
||||
}
|
||||
}
|
||||
|
||||
// Login verifies credentials and returns a signed JWT token. Kept for callers
|
||||
// that do not have request context (client IP); prefer LoginFrom.
|
||||
func (m *Manager) Login(username, password string) (token string, user *userstore.User, err error) {
|
||||
return m.LoginFrom(context.Background(), username, password, "")
|
||||
}
|
||||
|
||||
// LoginFrom verifies credentials, honouring each account's auth_source and,
|
||||
// where applicable, the tenant's LDAP configuration. ip is used for
|
||||
// rate-limiting and audit context.
|
||||
//
|
||||
// Decision matrix:
|
||||
// - account exists, auth_source=local : bcrypt only, LDAP never attempted.
|
||||
// - account exists, auth_source=ldap : LDAP bind only, no local-password fallback.
|
||||
// - account absent, tenant LDAP enabled: LDAP bind + JIT provisioning.
|
||||
// - otherwise : fail (with bcrypt timing burn).
|
||||
func (m *Manager) LoginFrom(ctx context.Context, identifier, password, ip string) (string, *userstore.User, error) {
|
||||
rec, err := m.store.FindForLogin(ctx, identifier)
|
||||
switch {
|
||||
case err == nil && rec.AuthSource == userstore.AuthSourceLDAP:
|
||||
if m.ldap == nil {
|
||||
return "", nil, fmt.Errorf("auth: login: invalid credentials")
|
||||
}
|
||||
return m.ldapLogin(ctx, derefTenant(rec.User.TenantID), identifier, password, ip, rec.User)
|
||||
|
||||
case err == nil:
|
||||
if !rec.User.Active {
|
||||
return "", nil, fmt.Errorf("auth: login: invalid credentials")
|
||||
}
|
||||
if cErr := userstore.CompareLocalPassword(rec.Hash, password); cErr != nil {
|
||||
return "", nil, fmt.Errorf("auth: login: invalid credentials")
|
||||
}
|
||||
return m.issueToken(rec.User)
|
||||
|
||||
case errors.Is(err, userstore.ErrUserNotFound):
|
||||
// Possible just-in-time LDAP provisioning.
|
||||
if tid := m.jitTenant(ctx, identifier); tid != nil {
|
||||
return m.ldapLogin(ctx, *tid, identifier, password, ip, nil)
|
||||
}
|
||||
m.store.BurnPasswordTiming(password)
|
||||
return "", nil, fmt.Errorf("auth: login: invalid credentials")
|
||||
|
||||
default:
|
||||
return "", nil, fmt.Errorf("auth: login: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// jitTenant returns the tenant ID an unknown identifier may be provisioned
|
||||
// into: the identifier's email domain must map to a tenant that has an enabled
|
||||
// LDAP config. Returns nil when JIT is not applicable.
|
||||
func (m *Manager) jitTenant(ctx context.Context, identifier string) *int64 {
|
||||
if m.ldap == nil {
|
||||
return nil
|
||||
}
|
||||
at := strings.LastIndex(identifier, "@")
|
||||
if at < 0 || at == len(identifier)-1 {
|
||||
return nil
|
||||
}
|
||||
domain := strings.ToLower(identifier[at+1:])
|
||||
tid, err := m.ldap.tenants.GetTenantIDByDomain(ctx, domain)
|
||||
if err != nil || tid == nil {
|
||||
return nil
|
||||
}
|
||||
cfg, err := m.ldap.store.Get(ctx, *tid)
|
||||
if err != nil || !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
return tid
|
||||
}
|
||||
|
||||
// ldapLogin performs the LDAP bind, applies role mapping, JIT-provisions or
|
||||
// re-synchronises the local user, and issues a token. existing may be nil (JIT).
|
||||
func (m *Manager) ldapLogin(ctx context.Context, tenantID int64, loginName, password, ip string, existing *userstore.User) (string, *userstore.User, error) {
|
||||
tid := tenantID
|
||||
logFail := func(detail string) {
|
||||
m.ldap.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventLdapLoginFailed, Username: loginName, IPAddress: ip,
|
||||
TenantID: &tid, Success: false, Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
// Rate limit per (tenant, loginName) and per source IP.
|
||||
userKey := fmt.Sprintf("u:%d:%s", tenantID, strings.ToLower(loginName))
|
||||
if !m.ldap.limiter.allow(userKey) || (ip != "" && !m.ldap.limiter.allow("ip:"+ip)) {
|
||||
logFail("rate_limited")
|
||||
return "", nil, fmt.Errorf("auth: login: too many attempts")
|
||||
}
|
||||
|
||||
cfg, bindPw, err := m.ldap.store.GetWithSecret(ctx, tenantID)
|
||||
if err != nil {
|
||||
logFail("config_unavailable")
|
||||
return "", nil, fmt.Errorf("auth: login: invalid credentials")
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
logFail("ldap_disabled")
|
||||
return "", nil, fmt.Errorf("auth: login: invalid credentials")
|
||||
}
|
||||
|
||||
res, err := m.ldap.authn.Authenticate(ctx, cfg, bindPw, loginName, password)
|
||||
if err != nil {
|
||||
logFail("bind_failed")
|
||||
return "", nil, fmt.Errorf("auth: login: invalid credentials")
|
||||
}
|
||||
|
||||
// Role mapping: admin group membership -> domain_admin, else user.
|
||||
// LDAP can NEVER confer superadmin.
|
||||
role := userstore.RoleUser
|
||||
if res.IsAdmin {
|
||||
role = userstore.RoleDomainAdmin
|
||||
}
|
||||
|
||||
var user *userstore.User
|
||||
if existing == nil {
|
||||
username := res.Username
|
||||
if username == "" {
|
||||
username = loginName
|
||||
}
|
||||
email := res.Email
|
||||
if email == "" {
|
||||
email = loginName
|
||||
}
|
||||
user, err = m.store.CreateLDAPUser(ctx, userstore.LDAPUserRequest{
|
||||
Username: username, Email: email, Role: role, LdapUID: res.Username, TenantID: &tid,
|
||||
})
|
||||
if err != nil {
|
||||
logFail("provision_failed")
|
||||
return "", nil, fmt.Errorf("auth: login: provisioning failed")
|
||||
}
|
||||
} else {
|
||||
if existing.Role != role && existing.Role != userstore.RoleSuperAdmin {
|
||||
m.ldap.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventLdapRoleSync, Username: existing.Username, IPAddress: ip,
|
||||
TenantID: &tid, Success: true,
|
||||
Detail: fmt.Sprintf("role %s -> %s", existing.Role, role),
|
||||
})
|
||||
}
|
||||
// Never downgrade a superadmin via LDAP (defensive; LDAP accounts are
|
||||
// never superadmin, but guard against manual DB edits).
|
||||
syncRole := role
|
||||
if existing.Role == userstore.RoleSuperAdmin {
|
||||
syncRole = userstore.RoleSuperAdmin
|
||||
}
|
||||
email := res.Email
|
||||
if email == "" {
|
||||
email = existing.Email
|
||||
}
|
||||
if err := m.store.SyncLDAPUser(ctx, existing.ID, email, syncRole); err != nil {
|
||||
logFail("sync_failed")
|
||||
return "", nil, fmt.Errorf("auth: login: sync failed")
|
||||
}
|
||||
user, err = m.store.GetByID(existing.ID)
|
||||
if err != nil {
|
||||
logFail("reload_failed")
|
||||
return "", nil, fmt.Errorf("auth: login: reload failed")
|
||||
}
|
||||
}
|
||||
|
||||
m.ldap.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventLdapLoginSuccess, Username: user.Username, IPAddress: ip,
|
||||
TenantID: &tid, Success: true, Detail: "role:" + user.Role,
|
||||
})
|
||||
return m.issueToken(user)
|
||||
}
|
||||
|
||||
func derefTenant(t *int64) int64 {
|
||||
if t == nil {
|
||||
return 0
|
||||
}
|
||||
return *t
|
||||
}
|
||||
|
||||
func (m *Manager) issueToken(user *userstore.User) (string, *userstore.User, error) {
|
||||
jti := generateJTI()
|
||||
now := time.Now()
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": user.Username,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"uid": user.ID,
|
||||
"jti": jti,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(8 * time.Hour).Unix(),
|
||||
}
|
||||
if user.TenantID != nil {
|
||||
claims["tenant_id"] = *user.TenantID
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString(m.jwtSecret)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("auth: sign token: %w", err)
|
||||
}
|
||||
return signed, user, nil
|
||||
}
|
||||
|
||||
// ValidateToken parses and validates the token, checking the blacklist.
|
||||
func (m *Manager) ValidateToken(tokenStr string) (*Session, error) {
|
||||
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("auth: unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return m.jwtSecret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: invalid token: %w", err)
|
||||
}
|
||||
if !token.Valid {
|
||||
return nil, errors.New("auth: token not valid")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, errors.New("auth: bad claims")
|
||||
}
|
||||
|
||||
jti, _ := claims["jti"].(string)
|
||||
blacklisted, err := m.store.IsBlacklisted(jti)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: blacklist check: %w", err)
|
||||
}
|
||||
if blacklisted {
|
||||
return nil, errors.New("auth: token revoked")
|
||||
}
|
||||
|
||||
username, _ := claims["sub"].(string)
|
||||
email, _ := claims["email"].(string)
|
||||
role, _ := claims["role"].(string)
|
||||
|
||||
var userID int64
|
||||
switch v := claims["uid"].(type) {
|
||||
case float64:
|
||||
userID = int64(v)
|
||||
case int64:
|
||||
userID = v
|
||||
}
|
||||
|
||||
var tenantID *int64
|
||||
switch v := claims["tenant_id"].(type) {
|
||||
case float64:
|
||||
id := int64(v)
|
||||
tenantID = &id
|
||||
case int64:
|
||||
id := v
|
||||
tenantID = &id
|
||||
}
|
||||
|
||||
return &Session{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Email: email,
|
||||
Role: role,
|
||||
JTI: jti,
|
||||
TenantID: tenantID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Logout revokes the token by adding its JTI to the blacklist.
|
||||
func (m *Manager) Logout(tokenStr string) error {
|
||||
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("auth: unexpected signing method")
|
||||
}
|
||||
return m.jwtSecret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: logout parse: %w", err)
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return errors.New("auth: bad claims on logout")
|
||||
}
|
||||
jti, _ := claims["jti"].(string)
|
||||
var exp time.Time
|
||||
switch v := claims["exp"].(type) {
|
||||
case float64:
|
||||
exp = time.Unix(int64(v), 0)
|
||||
case int64:
|
||||
exp = time.Unix(v, 0)
|
||||
default:
|
||||
exp = time.Now().Add(8 * time.Hour)
|
||||
}
|
||||
return m.store.BlacklistToken(jti, exp)
|
||||
}
|
||||
|
||||
// HasRole returns true when userRole satisfies the required role level.
|
||||
// Hierarchy: superadmin > domain_admin > user
|
||||
func HasRole(userRole, required string) bool {
|
||||
levels := map[string]int{
|
||||
userstore.RoleUser: 1,
|
||||
userstore.RoleDomainAdmin: 2,
|
||||
userstore.RoleSuperAdmin: 3,
|
||||
}
|
||||
return levels[userRole] >= levels[required]
|
||||
}
|
||||
|
||||
// generateJTI returns a cryptographically random identifier for a JWT.
|
||||
func generateJTI() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// GetUserStore returns the underlying user store.
|
||||
func (m *Manager) GetUserStore() *userstore.Store {
|
||||
return m.store
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// keyedRateLimiter is a minimal in-memory token-bucket limiter keyed by an
|
||||
// arbitrary string (used for per-(tenant,loginName) and per-IP LDAP login
|
||||
// throttling). No external dependency, no Redis — buckets are created lazily
|
||||
// and idle ones are swept periodically to bound memory.
|
||||
type keyedRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
burst float64
|
||||
refillPerSec float64
|
||||
lastSweep time.Time
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func newKeyedRateLimiter(burst, refillPerSec float64) *keyedRateLimiter {
|
||||
return &keyedRateLimiter{
|
||||
buckets: make(map[string]*bucket),
|
||||
burst: burst,
|
||||
refillPerSec: refillPerSec,
|
||||
lastSweep: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// allow consumes one token for key, returning false when the bucket is empty.
|
||||
func (l *keyedRateLimiter) allow(key string) bool {
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.sweepLocked(now)
|
||||
|
||||
b, ok := l.buckets[key]
|
||||
if !ok {
|
||||
b = &bucket{tokens: l.burst, last: now}
|
||||
l.buckets[key] = b
|
||||
}
|
||||
b.tokens += now.Sub(b.last).Seconds() * l.refillPerSec
|
||||
if b.tokens > l.burst {
|
||||
b.tokens = l.burst
|
||||
}
|
||||
b.last = now
|
||||
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *keyedRateLimiter) sweepLocked(now time.Time) {
|
||||
if now.Sub(l.lastSweep) < time.Minute {
|
||||
return
|
||||
}
|
||||
l.lastSweep = now
|
||||
for k, b := range l.buckets {
|
||||
if now.Sub(b.last) > 30*time.Minute {
|
||||
delete(l.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Package barcode wraps the system zbarimg binary (Debian package
|
||||
// zbar-tools) as a best-effort barcode decoding sidecar, consistent with
|
||||
// archivdms's general philosophy of shelling out to small CLI tools via
|
||||
// os/exec instead of adding CGO/native Go dependencies (see
|
||||
// internal/ocr/ocr.go package comment for the same rationale applied to
|
||||
// tesseract/poppler-utils).
|
||||
//
|
||||
// Barcode decoding failures (binary missing, non-zero exit because no
|
||||
// barcode was found, timeout) are never fatal to an upload: DecodeBarcodes
|
||||
// returns an empty slice and a nil error in the "nothing found / binary
|
||||
// missing" cases, matching how OCR failures are tolerated by callers.
|
||||
package barcode
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultTimeout = 20 * time.Second
|
||||
|
||||
// DecodeBarcodes runs `zbarimg --raw -q <imagePath>` and returns the
|
||||
// decoded raw values, one per line of output. If zbarimg is not installed,
|
||||
// this returns an empty slice and a nil error (tolerant, no hard-fail) —
|
||||
// callers that want to warn about a missing binary should check
|
||||
// exec.LookPath("zbarimg") themselves if they need to distinguish
|
||||
// "not installed" from "installed, nothing found".
|
||||
func DecodeBarcodes(ctx context.Context, imagePath string) ([]string, error) {
|
||||
if _, err := exec.LookPath("zbarimg"); err != nil {
|
||||
// Binary not present: tolerated, same as a missing tesseract binary.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cctx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(cctx, "zbarimg", "--raw", "-q", imagePath)
|
||||
var out, stderr bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
// zbarimg exits non-zero (typically exit code 4) when no barcode is
|
||||
// found in the image at all — that is a normal, expected outcome for
|
||||
// the vast majority of scanned documents, not an error condition.
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
_ = exitErr
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("barcode: zbarimg failed: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
|
||||
var values []string
|
||||
for _, line := range strings.Split(out.String(), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
values = append(values, line)
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
// Package classifier implements a dependency-free multinomial Naive-Bayes text
|
||||
// classifier that supplements archivdms's rule-based matching engine
|
||||
// (internal/matching) WITHOUT introducing any LLM dependency. It is designed to
|
||||
// give clean, self-standing results even when no Ollama/LLM is configured: the
|
||||
// tokenizer, German stop-word handling and Laplace smoothing are the quality
|
||||
// levers and are treated as first-class, not minimal.
|
||||
//
|
||||
// The model is persisted in the ml_classifier_tokens / ml_classifier_classes
|
||||
// tables (see internal/storage/ml_classifier.go). Training is a full
|
||||
// per-tenant/per-kind rebuild (DELETE + bulk insert) — deliberately simple, no
|
||||
// incremental updates. Classification (Predict) reads that persisted model and
|
||||
// returns softmax-normalised posterior probabilities so the caller can apply a
|
||||
// single confidence floor comparable to the heuristic provider's
|
||||
// suggestionFloor.
|
||||
//
|
||||
// This package MUST NOT import internal/storage (storage imports it, for
|
||||
// Store.GenerateNaiveBayesSuggestions) — it talks to Postgres through the small
|
||||
// DB interface below, which *pgxpool.Pool satisfies.
|
||||
package classifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// DB is the minimal Postgres surface the classifier needs. *pgxpool.Pool (and
|
||||
// pgx.Tx, for the value passed to Predict from within a transaction) satisfy it.
|
||||
type DB interface {
|
||||
Begin(ctx context.Context) (pgx.Tx, error)
|
||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||
}
|
||||
|
||||
const (
|
||||
// MinDocsPerClass is the minimum number of labelled training documents a
|
||||
// class (document_type / correspondent / tag) must have before it is kept in
|
||||
// the model. Below this the class is silently skipped (no error): too few
|
||||
// examples produce an over-confident, unreliable token distribution.
|
||||
MinDocsPerClass = 20
|
||||
|
||||
// laplaceAlpha is the additive (Laplace/Lidstone) smoothing constant applied
|
||||
// to every token count. alpha=1 (classic add-one) keeps unseen tokens from
|
||||
// zeroing out a whole class's likelihood while staying conservative for
|
||||
// small vocabularies.
|
||||
laplaceAlpha = 1.0
|
||||
|
||||
// SuggestionFloor is the minimum softmax posterior probability at which a
|
||||
// class is surfaced as a candidate. Chosen to mirror the heuristic
|
||||
// provider's suggestionFloor (0.55) so the two engines feel consistent to
|
||||
// the user: below this the model is essentially undecided between classes.
|
||||
SuggestionFloor = 0.55
|
||||
|
||||
// maxCandidates caps how many classes Predict returns, sorted by posterior
|
||||
// descending (matches maxSuggestionCandidates in the storage layer).
|
||||
maxCandidates = 5
|
||||
|
||||
// maxExplanationTokens is how many "most decisive" tokens are attached to a
|
||||
// candidate as a human-readable explanation.
|
||||
maxExplanationTokens = 5
|
||||
|
||||
// minTokenRunes / maxTokenRunes bound token length: 1-rune tokens are noise;
|
||||
// absurdly long runs are almost always OCR garbage (barcodes, scan
|
||||
// artefacts) rather than meaningful words.
|
||||
minTokenRunes = 2
|
||||
maxTokenRunes = 40
|
||||
|
||||
// minNumericTokenRunes: purely numeric tokens shorter than this (page
|
||||
// numbers, single amounts, "1."/"12") are dropped as noise, but longer
|
||||
// numeric runs are KEPT — invoice/customer numbers, IBAN fragments and years
|
||||
// recur across a correspondent's documents and carry real signal.
|
||||
minNumericTokenRunes = 4
|
||||
)
|
||||
|
||||
// Kinds are the three classifiable taxonomy kinds. Mirrors the CHECK constraint
|
||||
// on ml_classifier_tokens.kind.
|
||||
const (
|
||||
KindDocumentTypes = "document_types"
|
||||
KindCorrespondents = "correspondents"
|
||||
KindTags = "tags"
|
||||
)
|
||||
|
||||
// SuggestionCandidate is one scored class prediction. Score is a softmax
|
||||
// posterior probability in [0,1]. TopTokens lists (at most maxExplanationTokens)
|
||||
// input tokens that contributed most to selecting this class over the runner-up
|
||||
// — the model's explanation for GoBD-Nachvollziehbarkeit / user trust.
|
||||
type SuggestionCandidate struct {
|
||||
EntityID int64 `json:"entity_id"`
|
||||
Score float64 `json:"score"`
|
||||
TopTokens []string `json:"top_tokens"`
|
||||
}
|
||||
|
||||
// Classifier is a thin, stateless wrapper around a DB handle. Safe to construct
|
||||
// per call.
|
||||
type Classifier struct {
|
||||
db DB
|
||||
}
|
||||
|
||||
// New returns a Classifier backed by db.
|
||||
func New(db DB) *Classifier {
|
||||
return &Classifier{db: db}
|
||||
}
|
||||
|
||||
// validKind guards the kind against the allowlist so it can be interpolated
|
||||
// nowhere (all queries parameterise it) but callers still fail fast on typos.
|
||||
func validKind(kind string) error {
|
||||
switch kind {
|
||||
case KindDocumentTypes, KindCorrespondents, KindTags:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("classifier: unknown kind %q", kind)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tokenizer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// germanStopwords is a curated list of high-frequency German function words
|
||||
// (plus a few ubiquitous document-boilerplate terms) that carry no class
|
||||
// signal. Removing them sharpens the per-class token distributions. Kept as a
|
||||
// set for O(1) lookup. Not exhaustive by design — the goal is to strip the
|
||||
// worst offenders, not to stem the language.
|
||||
var germanStopwords = func() map[string]struct{} {
|
||||
words := []string{
|
||||
"der", "die", "das", "den", "dem", "des", "ein", "eine", "einen", "einem",
|
||||
"einer", "eines", "und", "oder", "aber", "auch", "sich", "nicht", "mit",
|
||||
"für", "von", "vom", "im", "in", "am", "an", "auf", "aus", "bei", "bis",
|
||||
"durch", "gegen", "ohne", "um", "unter", "über", "zwischen", "nach", "zu",
|
||||
"zur", "zum", "vor", "hinter", "neben", "ist", "sind", "war", "waren",
|
||||
"wird", "werden", "wurde", "wurden", "sein", "seine", "seiner", "ihre",
|
||||
"ihrer", "ihren", "haben", "hat", "hatte", "hatten", "kann", "können",
|
||||
"muss", "müssen", "soll", "sollen", "als", "wie", "wenn", "dann", "dass",
|
||||
"daß", "weil", "denn", "doch", "nur", "noch", "schon", "sehr", "hier",
|
||||
"dort", "man", "wir", "sie", "ich", "du", "er", "es", "ihr", "uns",
|
||||
"euch", "mein", "dein", "unser", "diese", "dieser", "dieses", "diesem",
|
||||
"jede", "jeder", "jedes", "alle", "allen", "aller", "kein", "keine",
|
||||
"keinen", "mehr", "sehr", "so", "auch", "wieder", "bitte", "danke",
|
||||
"gmbh", "seite", "www", "http", "https", "email", "mail", "tel",
|
||||
}
|
||||
m := make(map[string]struct{}, len(words))
|
||||
for _, w := range words {
|
||||
m[w] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// tokenize normalises text into a bag of meaningful tokens:
|
||||
// - Unicode-aware lowercasing.
|
||||
// - A token is a maximal run of letters and/or digits (so "de89370400"
|
||||
// survives as one token, and dots/slashes/whitespace all split). This keeps
|
||||
// structured identifiers (IBAN fragments, invoice numbers) intact instead of
|
||||
// shredding them into single digits.
|
||||
// - German stop-words are dropped.
|
||||
// - Tokens shorter than minTokenRunes or longer than maxTokenRunes are dropped.
|
||||
// - Purely numeric tokens shorter than minNumericTokenRunes are dropped
|
||||
// (page numbers, trivial amounts), longer ones are kept (they recur and
|
||||
// carry signal). Mixed letter+digit tokens are always kept.
|
||||
//
|
||||
// Returns a frequency map (token -> count in this text), which is exactly what
|
||||
// the multinomial model consumes.
|
||||
func tokenize(text string) map[string]int {
|
||||
freq := make(map[string]int)
|
||||
var b strings.Builder
|
||||
flush := func() {
|
||||
if b.Len() == 0 {
|
||||
return
|
||||
}
|
||||
tok := b.String()
|
||||
b.Reset()
|
||||
addToken(freq, tok)
|
||||
}
|
||||
for _, r := range text {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
}
|
||||
flush()
|
||||
return freq
|
||||
}
|
||||
|
||||
// addToken applies the length / numeric / stop-word filters and, if the token
|
||||
// survives, increments its frequency.
|
||||
func addToken(freq map[string]int, tok string) {
|
||||
runes := []rune(tok)
|
||||
if len(runes) < minTokenRunes || len(runes) > maxTokenRunes {
|
||||
return
|
||||
}
|
||||
if _, stop := germanStopwords[tok]; stop {
|
||||
return
|
||||
}
|
||||
if isAllDigits(runes) && len(runes) < minNumericTokenRunes {
|
||||
return
|
||||
}
|
||||
freq[tok]++
|
||||
}
|
||||
|
||||
func isAllDigits(runes []rune) bool {
|
||||
for _, r := range runes {
|
||||
if !unicode.IsDigit(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Training
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// classAccum accumulates token statistics for one class during training.
|
||||
type classAccum struct {
|
||||
docCount int64
|
||||
totalTokens int64
|
||||
tokens map[string]int64
|
||||
}
|
||||
|
||||
// Train rebuilds the Naive-Bayes model for one tenant and one kind from scratch.
|
||||
// It reads every labelled, non-deleted training document (assignments made
|
||||
// manually OR by the rule engine — NOT prior ml_accepted ones, to avoid the
|
||||
// model reinforcing its own past guesses), tokenizes the title+OCR text, counts
|
||||
// tokens per class, drops classes with fewer than MinDocsPerClass documents, and
|
||||
// writes the result with a DELETE + bulk COPY inside a single transaction
|
||||
// (previous model for this tenant/kind is fully replaced).
|
||||
//
|
||||
// Returns the number of training documents actually used (across retained
|
||||
// classes). A kind with no qualifying data trains to an empty model and returns
|
||||
// 0 — this is not an error.
|
||||
func (c *Classifier) Train(ctx context.Context, tenantID int64, kind string) (docCount int, err error) {
|
||||
if err := validKind(kind); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
rows, err := c.db.Query(ctx, trainingQuery(kind), tenantID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("classifier: read training data (%s): %w", kind, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
classes := make(map[int64]*classAccum)
|
||||
for rows.Next() {
|
||||
var entityID int64
|
||||
var title, ocr string
|
||||
if err := rows.Scan(&entityID, &title, &ocr); err != nil {
|
||||
return 0, fmt.Errorf("classifier: scan training row (%s): %w", kind, err)
|
||||
}
|
||||
acc := classes[entityID]
|
||||
if acc == nil {
|
||||
acc = &classAccum{tokens: make(map[string]int64)}
|
||||
classes[entityID] = acc
|
||||
}
|
||||
acc.docCount++
|
||||
for tok, n := range tokenize(title + "\n" + ocr) {
|
||||
acc.tokens[tok] += int64(n)
|
||||
acc.totalTokens += int64(n)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, fmt.Errorf("classifier: iterate training rows (%s): %w", kind, err)
|
||||
}
|
||||
|
||||
// Keep only classes with enough evidence.
|
||||
retained := make(map[int64]*classAccum)
|
||||
used := 0
|
||||
for id, acc := range classes {
|
||||
if acc.docCount < MinDocsPerClass {
|
||||
continue
|
||||
}
|
||||
retained[id] = acc
|
||||
used += int(acc.docCount)
|
||||
}
|
||||
|
||||
if err := c.persist(ctx, tenantID, kind, retained); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return used, nil
|
||||
}
|
||||
|
||||
// trainingQuery returns the SQL that yields (entity_id, title, ocr_text) rows
|
||||
// for a kind, restricted to manual/rule-assigned, non-deleted documents.
|
||||
func trainingQuery(kind string) string {
|
||||
switch kind {
|
||||
case KindDocumentTypes:
|
||||
return `
|
||||
SELECT d.doc_type_id, d.title, COALESCE(d.ocr_text, '')
|
||||
FROM documents d
|
||||
WHERE d.tenant_id = $1
|
||||
AND d.deleted_at IS NULL
|
||||
AND d.doc_type_id IS NOT NULL
|
||||
AND d.doc_type_assigned_via IN ('manual','rule')`
|
||||
case KindCorrespondents:
|
||||
return `
|
||||
SELECT d.correspondent_id, d.title, COALESCE(d.ocr_text, '')
|
||||
FROM documents d
|
||||
WHERE d.tenant_id = $1
|
||||
AND d.deleted_at IS NULL
|
||||
AND d.correspondent_id IS NOT NULL
|
||||
AND d.correspondent_assigned_via IN ('manual','rule')`
|
||||
case KindTags:
|
||||
return `
|
||||
SELECT dt.tag_id, d.title, COALESCE(d.ocr_text, '')
|
||||
FROM document_tags dt
|
||||
JOIN documents d ON d.id = dt.document_id
|
||||
WHERE d.tenant_id = $1
|
||||
AND d.deleted_at IS NULL
|
||||
AND dt.assigned_via IN ('manual','rule')`
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// persist replaces the stored model for tenant/kind with the retained classes,
|
||||
// atomically (DELETE + COPY inside one transaction). An empty retained map still
|
||||
// clears the previous model — a class that dropped below the threshold must not
|
||||
// keep serving stale predictions.
|
||||
func (c *Classifier) persist(ctx context.Context, tenantID int64, kind string, retained map[int64]*classAccum) error {
|
||||
tx, err := c.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("classifier: begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM ml_classifier_tokens WHERE tenant_id = $1 AND kind = $2`, tenantID, kind); err != nil {
|
||||
return fmt.Errorf("classifier: clear tokens: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM ml_classifier_classes WHERE tenant_id = $1 AND kind = $2`, tenantID, kind); err != nil {
|
||||
return fmt.Errorf("classifier: clear classes: %w", err)
|
||||
}
|
||||
|
||||
classRows := make([][]any, 0, len(retained))
|
||||
tokenRows := make([][]any, 0)
|
||||
for entityID, acc := range retained {
|
||||
classRows = append(classRows, []any{tenantID, kind, entityID, acc.docCount, acc.totalTokens})
|
||||
for tok, cnt := range acc.tokens {
|
||||
tokenRows = append(tokenRows, []any{tenantID, kind, entityID, tok, cnt})
|
||||
}
|
||||
}
|
||||
|
||||
if len(classRows) > 0 {
|
||||
if _, err := tx.CopyFrom(ctx,
|
||||
pgx.Identifier{"ml_classifier_classes"},
|
||||
[]string{"tenant_id", "kind", "entity_id", "doc_count", "total_tokens"},
|
||||
pgx.CopyFromRows(classRows)); err != nil {
|
||||
return fmt.Errorf("classifier: copy classes: %w", err)
|
||||
}
|
||||
}
|
||||
if len(tokenRows) > 0 {
|
||||
if _, err := tx.CopyFrom(ctx,
|
||||
pgx.Identifier{"ml_classifier_tokens"},
|
||||
[]string{"tenant_id", "kind", "entity_id", "token", "count"},
|
||||
pgx.CopyFromRows(tokenRows)); err != nil {
|
||||
return fmt.Errorf("classifier: copy tokens: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("classifier: commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prediction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// classStat holds the persisted per-class statistics needed for scoring.
|
||||
type classStat struct {
|
||||
entityID int64
|
||||
docCount int64
|
||||
totalTokens int64
|
||||
logLikeAt float64 // running log-likelihood accumulator (built during Predict)
|
||||
}
|
||||
|
||||
// Predict scores the given text against the trained model for tenant/kind and
|
||||
// returns the classes whose softmax posterior probability is at least
|
||||
// SuggestionFloor, top maxCandidates, sorted by score descending. Each candidate
|
||||
// carries its most decisive tokens as an explanation.
|
||||
//
|
||||
// Scoring is the standard multinomial Naive-Bayes log-likelihood with Laplace
|
||||
// smoothing:
|
||||
//
|
||||
// logscore(c) = log P(c) + Σ_t freq(t) · log( (count(t,c)+α) / (Σtokens_c + α·V) )
|
||||
//
|
||||
// where V is the tenant/kind vocabulary size. The log-scores are then softmaxed
|
||||
// (max-subtracted for numerical stability) into posterior probabilities so a
|
||||
// single, interpretable confidence floor can be applied.
|
||||
func (c *Classifier) Predict(ctx context.Context, tenantID int64, kind string, text string) ([]SuggestionCandidate, error) {
|
||||
if err := validKind(kind); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats, totalDocs, err := c.loadClasses(ctx, tenantID, kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(stats) == 0 || totalDocs == 0 {
|
||||
return []SuggestionCandidate{}, nil // untrained kind: no suggestions, not an error
|
||||
}
|
||||
|
||||
vocab, err := c.vocabSize(ctx, tenantID, kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
freq := tokenize(text)
|
||||
if len(freq) == 0 {
|
||||
return []SuggestionCandidate{}, nil
|
||||
}
|
||||
tokens := make([]string, 0, len(freq))
|
||||
for t := range freq {
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
|
||||
// tokenCounts[token][entityID] = stored count.
|
||||
tokenCounts, err := c.loadTokenCounts(ctx, tenantID, kind, tokens)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Per-token, per-class smoothed log-probability, plus per-class log score.
|
||||
// perTokenLog[token][entityID] retained for the explanation step.
|
||||
perTokenLog := make(map[string]map[int64]float64, len(tokens))
|
||||
for i := range stats {
|
||||
st := &stats[i]
|
||||
st.logLikeAt = math.Log(float64(st.docCount) / float64(totalDocs)) // log prior
|
||||
}
|
||||
denom := make(map[int64]float64, len(stats))
|
||||
for i := range stats {
|
||||
st := &stats[i]
|
||||
denom[st.entityID] = float64(st.totalTokens) + laplaceAlpha*float64(vocab)
|
||||
}
|
||||
|
||||
for _, tok := range tokens {
|
||||
perClass := tokenCounts[tok]
|
||||
logs := make(map[int64]float64, len(stats))
|
||||
for i := range stats {
|
||||
st := &stats[i]
|
||||
cnt := float64(perClass[st.entityID]) // 0 if unseen
|
||||
logp := math.Log((cnt + laplaceAlpha) / denom[st.entityID])
|
||||
logs[st.entityID] = logp
|
||||
st.logLikeAt += float64(freq[tok]) * logp
|
||||
}
|
||||
perTokenLog[tok] = logs
|
||||
}
|
||||
|
||||
// Softmax over the class log-scores.
|
||||
scores := softmax(stats)
|
||||
|
||||
cands := make([]SuggestionCandidate, 0, len(stats))
|
||||
// Rank runner-up for the explanation (second-highest posterior).
|
||||
for i := range stats {
|
||||
st := stats[i]
|
||||
p := scores[st.entityID]
|
||||
if p < SuggestionFloor {
|
||||
continue
|
||||
}
|
||||
cands = append(cands, SuggestionCandidate{
|
||||
EntityID: st.entityID,
|
||||
Score: p,
|
||||
TopTokens: decisiveTokens(st.entityID, stats, freq, perTokenLog),
|
||||
})
|
||||
}
|
||||
sort.SliceStable(cands, func(i, j int) bool { return cands[i].Score > cands[j].Score })
|
||||
if len(cands) > maxCandidates {
|
||||
cands = cands[:maxCandidates]
|
||||
}
|
||||
return cands, nil
|
||||
}
|
||||
|
||||
// softmax converts the per-class log scores into posterior probabilities,
|
||||
// subtracting the max log score first for numerical stability.
|
||||
func softmax(stats []classStat) map[int64]float64 {
|
||||
maxLog := math.Inf(-1)
|
||||
for i := range stats {
|
||||
if stats[i].logLikeAt > maxLog {
|
||||
maxLog = stats[i].logLikeAt
|
||||
}
|
||||
}
|
||||
sum := 0.0
|
||||
exp := make(map[int64]float64, len(stats))
|
||||
for i := range stats {
|
||||
e := math.Exp(stats[i].logLikeAt - maxLog)
|
||||
exp[stats[i].entityID] = e
|
||||
sum += e
|
||||
}
|
||||
out := make(map[int64]float64, len(stats))
|
||||
if sum == 0 {
|
||||
return out
|
||||
}
|
||||
for id, e := range exp {
|
||||
out[id] = e / sum
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decisiveTokens returns the (up to maxExplanationTokens) input tokens that most
|
||||
// favoured winner over the strongest competing class, weighted by their
|
||||
// frequency in the text. Positive margin = the token pushed toward winner.
|
||||
func decisiveTokens(winner int64, stats []classStat, freq map[string]int, perTokenLog map[string]map[int64]float64) []string {
|
||||
type scored struct {
|
||||
token string
|
||||
margin float64
|
||||
}
|
||||
out := make([]scored, 0, len(freq))
|
||||
for tok, logs := range perTokenLog {
|
||||
winLog, ok := logs[winner]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Best competing class's log-prob for this token.
|
||||
competitor := math.Inf(-1)
|
||||
for _, st := range stats {
|
||||
if st.entityID == winner {
|
||||
continue
|
||||
}
|
||||
if l := logs[st.entityID]; l > competitor {
|
||||
competitor = l
|
||||
}
|
||||
}
|
||||
if math.IsInf(competitor, -1) {
|
||||
competitor = winLog // single-class case: no margin
|
||||
}
|
||||
margin := float64(freq[tok]) * (winLog - competitor)
|
||||
if margin <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, scored{token: tok, margin: margin})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].margin > out[j].margin })
|
||||
tokens := make([]string, 0, maxExplanationTokens)
|
||||
for _, s := range out {
|
||||
if len(tokens) >= maxExplanationTokens {
|
||||
break
|
||||
}
|
||||
tokens = append(tokens, s.token)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
// loadClasses reads the persisted per-class stats for tenant/kind and the total
|
||||
// document count across them (the denominator of the class priors).
|
||||
func (c *Classifier) loadClasses(ctx context.Context, tenantID int64, kind string) ([]classStat, int64, error) {
|
||||
rows, err := c.db.Query(ctx,
|
||||
`SELECT entity_id, doc_count, total_tokens
|
||||
FROM ml_classifier_classes
|
||||
WHERE tenant_id = $1 AND kind = $2`, tenantID, kind)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("classifier: load classes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []classStat
|
||||
var totalDocs int64
|
||||
for rows.Next() {
|
||||
var st classStat
|
||||
if err := rows.Scan(&st.entityID, &st.docCount, &st.totalTokens); err != nil {
|
||||
return nil, 0, fmt.Errorf("classifier: scan class: %w", err)
|
||||
}
|
||||
totalDocs += st.docCount
|
||||
out = append(out, st)
|
||||
}
|
||||
return out, totalDocs, rows.Err()
|
||||
}
|
||||
|
||||
// vocabSize returns the number of distinct tokens in the tenant/kind model — the
|
||||
// V in Laplace smoothing.
|
||||
func (c *Classifier) vocabSize(ctx context.Context, tenantID int64, kind string) (int64, error) {
|
||||
var v int64
|
||||
if err := c.db.QueryRow(ctx,
|
||||
`SELECT COUNT(DISTINCT token) FROM ml_classifier_tokens WHERE tenant_id = $1 AND kind = $2`,
|
||||
tenantID, kind).Scan(&v); err != nil {
|
||||
return 0, fmt.Errorf("classifier: vocab size: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// loadTokenCounts fetches the per-class counts for exactly the input tokens
|
||||
// (one query, token = ANY($3)), returning token -> entityID -> count.
|
||||
func (c *Classifier) loadTokenCounts(ctx context.Context, tenantID int64, kind string, tokens []string) (map[string]map[int64]int64, error) {
|
||||
out := make(map[string]map[int64]int64, len(tokens))
|
||||
if len(tokens) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := c.db.Query(ctx,
|
||||
`SELECT token, entity_id, count
|
||||
FROM ml_classifier_tokens
|
||||
WHERE tenant_id = $1 AND kind = $2 AND token = ANY($3)`,
|
||||
tenantID, kind, tokens)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("classifier: load token counts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var tok string
|
||||
var entityID, cnt int64
|
||||
if err := rows.Scan(&tok, &entityID, &cnt); err != nil {
|
||||
return nil, fmt.Errorf("classifier: scan token count: %w", err)
|
||||
}
|
||||
m := out[tok]
|
||||
if m == nil {
|
||||
m = make(map[int64]int64)
|
||||
out[tok] = m
|
||||
}
|
||||
m[entityID] = cnt
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Package cryptutil provides authenticated symmetric encryption (AES-256-GCM)
|
||||
// for secrets that must be stored at rest but read back in plaintext at
|
||||
// runtime — currently the LDAP service-bind password (internal/ldapstore).
|
||||
//
|
||||
// The 256-bit key is derived via HKDF-SHA256 from the application's existing
|
||||
// master/JWT secret (config.API.Secret), so no additional secret needs to be
|
||||
// provisioned. A distinct HKDF info label keeps this key independent from the
|
||||
// JWT signing key even though both originate from the same input secret.
|
||||
package cryptutil
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
|
||||
// hkdfInfo domain-separates the secretbox key from every other key derived
|
||||
// from the same master secret (e.g. the JWT signing key uses "archivdms-jwt-v1").
|
||||
const hkdfInfo = "archivdms-ldap-secretbox-v1"
|
||||
|
||||
// Box performs AES-256-GCM encrypt/decrypt with a key derived from a secret.
|
||||
type Box struct {
|
||||
gcm cipher.AEAD
|
||||
}
|
||||
|
||||
// NewBox derives a 256-bit AES key from secret via HKDF-SHA256 and returns a
|
||||
// ready-to-use Box. secret must be non-empty.
|
||||
func NewBox(secret string) (*Box, error) {
|
||||
if secret == "" {
|
||||
return nil, fmt.Errorf("cryptutil: empty secret")
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err := io.ReadFull(hkdf.New(sha256.New, []byte(secret), nil, []byte(hkdfInfo)), key); err != nil {
|
||||
return nil, fmt.Errorf("cryptutil: derive key: %w", err)
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cryptutil: new cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cryptutil: new gcm: %w", err)
|
||||
}
|
||||
return &Box{gcm: gcm}, nil
|
||||
}
|
||||
|
||||
// Encrypt seals plaintext, returning the ciphertext and the freshly generated
|
||||
// nonce (stored separately in the DB). Callers persist both.
|
||||
func (b *Box) Encrypt(plaintext []byte) (ciphertext, nonce []byte, err error) {
|
||||
nonce = make([]byte, b.gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, nil, fmt.Errorf("cryptutil: nonce: %w", err)
|
||||
}
|
||||
ciphertext = b.gcm.Seal(nil, nonce, plaintext, nil)
|
||||
return ciphertext, nonce, nil
|
||||
}
|
||||
|
||||
// Decrypt opens ciphertext using nonce, returning the original plaintext.
|
||||
func (b *Box) Decrypt(ciphertext, nonce []byte) ([]byte, error) {
|
||||
if len(nonce) != b.gcm.NonceSize() {
|
||||
return nil, fmt.Errorf("cryptutil: bad nonce length %d", len(nonce))
|
||||
}
|
||||
plaintext, err := b.gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cryptutil: decrypt: %w", err)
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Package dateformat translates a small, user-friendly token pattern (e.g.
|
||||
// "DD.MM.YYYY HH:mm") into a Go time layout ("02.01.2006 15:04"). It is a
|
||||
// neutral package imported by both internal/api and internal/tenantstore so
|
||||
// they can share one validator without importing each other (which would be a
|
||||
// circular dependency).
|
||||
//
|
||||
// Admins may enter an arbitrary token string; anything that is not a known
|
||||
// token is preserved verbatim as literal text in the resulting layout.
|
||||
package dateformat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// MaxPatternLen bounds the token pattern length, mirroring the scan-title
|
||||
// prefix limit so an oversized value can never be persisted.
|
||||
const MaxPatternLen = 40
|
||||
|
||||
// tokenLayout maps each supported token to its Go reference-time fragment.
|
||||
// Order matters at match time (longest first) so e.g. "YYYY" is not consumed
|
||||
// as "YY"+"YY"; the regex alternation below encodes that ordering explicitly.
|
||||
var tokenLayout = map[string]string{
|
||||
"AM/PM": "PM",
|
||||
"YYYY": "2006",
|
||||
"YY": "06",
|
||||
"MM": "01",
|
||||
"DD": "02",
|
||||
"HH": "15",
|
||||
"hh": "03",
|
||||
"mm": "04",
|
||||
"ss": "05",
|
||||
"PM": "PM",
|
||||
}
|
||||
|
||||
// tokenRE matches known tokens, longest alternatives first so a greedy leftmost
|
||||
// match never splits a long token into shorter ones.
|
||||
var tokenRE = regexp.MustCompile(`AM/PM|YYYY|YY|MM|DD|HH|hh|mm|ss|PM`)
|
||||
|
||||
// Translate converts a token pattern into a Go time layout. It fails when the
|
||||
// pattern is empty, too long, or contains no recognised token (a pattern of
|
||||
// pure literal text would make time.Format return that literal unchanged,
|
||||
// which is never what the admin intends).
|
||||
func Translate(pattern string) (goLayout string, err error) {
|
||||
if pattern == "" {
|
||||
return "", fmt.Errorf("Format darf nicht leer sein")
|
||||
}
|
||||
if utf8.RuneCountInString(pattern) > MaxPatternLen {
|
||||
return "", fmt.Errorf("Format zu lang (max %d Zeichen)", MaxPatternLen)
|
||||
}
|
||||
matched := false
|
||||
layout := tokenRE.ReplaceAllStringFunc(pattern, func(tok string) string {
|
||||
matched = true
|
||||
return tokenLayout[tok]
|
||||
})
|
||||
if !matched {
|
||||
return "", fmt.Errorf("Format muss mindestens einen Datums-/Zeit-Platzhalter enthalten")
|
||||
}
|
||||
return layout, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Package index is the (Phase 1) full-text search sync layer for archivdms.
|
||||
//
|
||||
// PostgreSQL remains the single source of truth; this package keeps a
|
||||
// secondary, per-tenant Manticore Search index (Hybrid BM25+Vektor is a later
|
||||
// phase) in sync with the documents table. Only the write/sync half is
|
||||
// implemented here — there is deliberately NO search endpoint yet (Phase 2/3).
|
||||
//
|
||||
// Design guarantees:
|
||||
// - The index is best-effort. When Manticore is not configured (empty DSN)
|
||||
// the whole thing degrades to a no-op: the Indexer is nil and every caller
|
||||
// skips silently.
|
||||
// - An index error must NEVER be propagated to the originating HTTP request.
|
||||
// Callers log and move on. Postgres stays authoritative, so a stale index
|
||||
// is a recoverable, non-fatal condition (a later reindex CLI, Phase 3,
|
||||
// rebuilds it).
|
||||
//
|
||||
// This package intentionally has NO dependency on internal/storage to avoid an
|
||||
// import cycle: storage builds DocumentDoc values and calls into here.
|
||||
package index
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DocumentDoc is the index representation of a stored document. It is the
|
||||
// projection of a documents row plus its resolved taxonomy (tags) and ACL
|
||||
// (visibility group IDs) that the search index needs.
|
||||
type DocumentDoc struct {
|
||||
ID int64
|
||||
TenantID int64
|
||||
Title string
|
||||
DocType string // deprecated free-text doc_type
|
||||
Correspondent string // deprecated free-text correspondent
|
||||
OCRText string
|
||||
Tags []string
|
||||
TagIDs []int64
|
||||
DocTypeID *int64
|
||||
CorrespondentID *int64
|
||||
ACLGroupIDs []int64
|
||||
RetainUntil *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// SearchQuery is the (Phase 3) full-text + attribute query against a single
|
||||
// tenant's index. It intentionally carries only what the index needs to return
|
||||
// a ranked list of documents.id values; the caller re-hydrates the full
|
||||
// document rows from Postgres (the source of truth) afterwards.
|
||||
type SearchQuery struct {
|
||||
// Query is the raw user full-text term. It is escaped before it ever
|
||||
// reaches a MATCH() expression — callers pass it verbatim.
|
||||
Query string
|
||||
// TagIDs, when non-empty, restricts hits to documents carrying ANY of
|
||||
// these tag ids (MVA filter).
|
||||
TagIDs []int64
|
||||
// DocTypeID, when non-nil, restricts hits to that document type.
|
||||
DocTypeID *int64
|
||||
// ACLGroupIDs applies the group-resolved document ACL: when non-nil, only
|
||||
// documents visible to ANY of these permission groups are returned. A nil
|
||||
// slice means "no ACL filter" (domain_admin/superadmin). An explicitly
|
||||
// empty (non-nil) slice would match nothing — callers must short-circuit
|
||||
// that case before querying.
|
||||
ACLGroupIDs []int64
|
||||
// Page is 1-based; PageSize caps hits per page.
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// SearchHit is a single ranked result: a documents.id plus its BM25 score.
|
||||
type SearchHit struct {
|
||||
ID int64
|
||||
Score float64
|
||||
}
|
||||
|
||||
// Indexer syncs a single (tenant-scoped) document index. Implementations must
|
||||
// never block or fail the calling request on transient backend errors beyond
|
||||
// returning the error for the caller to log.
|
||||
type Indexer interface {
|
||||
// IndexSync inserts or replaces the document (id-based upsert).
|
||||
IndexSync(ctx context.Context, doc DocumentDoc) error
|
||||
// Delete removes the document from the index by its documents.id.
|
||||
Delete(ctx context.Context, id int64) error
|
||||
// Search runs a full-text + attribute query and returns the ranked hits
|
||||
// for the requested page plus the total match count (across all pages).
|
||||
Search(ctx context.Context, q SearchQuery) (hits []SearchHit, total int, err error)
|
||||
}
|
||||
|
||||
// TenantIndexer hands out per-tenant Indexer instances, each backed by its own
|
||||
// RT table (documents_tenant_<id>).
|
||||
type TenantIndexer interface {
|
||||
ForTenant(tenantID int64) Indexer
|
||||
Close() error
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// validTableName guards against SQL injection through table-name
|
||||
// interpolation: only documents_tenant_<digits> is ever a legal RT table.
|
||||
var validTableName = regexp.MustCompile(`^documents_tenant_\d+$`)
|
||||
|
||||
// manticoreIndex implements Indexer against a single Manticore RT table.
|
||||
type manticoreIndex struct {
|
||||
db *sql.DB
|
||||
table string
|
||||
}
|
||||
|
||||
// ManticoreTenantManager implements TenantIndexer using Manticore Search via
|
||||
// the MySQL wire protocol (port 9306 by default). No CGO required — pure Go
|
||||
// through database/sql + github.com/go-sql-driver/mysql.
|
||||
type ManticoreTenantManager struct {
|
||||
db *sql.DB
|
||||
mu sync.RWMutex
|
||||
pool map[int64]*manticoreIndex
|
||||
}
|
||||
|
||||
// NewManticoreTenantManager opens (and pings) a Manticore connection and
|
||||
// returns a ready manager. Per-tenant RT tables are created lazily on first
|
||||
// ForTenant use.
|
||||
func NewManticoreTenantManager(dsn string) (*ManticoreTenantManager, error) {
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("manticore: open: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(16)
|
||||
db.SetMaxIdleConns(4)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("manticore: ping: %w", err)
|
||||
}
|
||||
|
||||
return &ManticoreTenantManager{
|
||||
db: db,
|
||||
pool: make(map[int64]*manticoreIndex),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ForTenant returns the Indexer for a tenant, creating its RT table on first
|
||||
// use. If the table cannot be ensured, a no-op Indexer is returned so callers
|
||||
// never panic or block — the miss is the caller's to log.
|
||||
func (m *ManticoreTenantManager) ForTenant(tenantID int64) Indexer {
|
||||
if tenantID <= 0 {
|
||||
return noopIndexer{}
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
idx, ok := m.pool[tenantID]
|
||||
m.mu.RUnlock()
|
||||
if ok {
|
||||
return idx
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if idx, ok = m.pool[tenantID]; ok {
|
||||
return idx
|
||||
}
|
||||
|
||||
idx = &manticoreIndex{db: m.db, table: manticoreTableName(tenantID)}
|
||||
if err := idx.ensureTable(); err != nil {
|
||||
return noopIndexer{}
|
||||
}
|
||||
m.pool[tenantID] = idx
|
||||
return idx
|
||||
}
|
||||
|
||||
// Close closes the shared database connection.
|
||||
func (m *ManticoreTenantManager) Close() error {
|
||||
return m.db.Close()
|
||||
}
|
||||
|
||||
// ── manticoreIndex methods ────────────────────────────────────────────────
|
||||
|
||||
// ensureTable creates the RT index idempotently if it does not yet exist.
|
||||
func (idx *manticoreIndex) ensureTable() error {
|
||||
stmt := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
||||
doc_id string,
|
||||
title text,
|
||||
doc_type text,
|
||||
correspondent text,
|
||||
ocr_text text,
|
||||
tags text,
|
||||
tag_ids multi,
|
||||
doc_type_id bigint,
|
||||
correspondent_id bigint,
|
||||
acl_group_ids multi,
|
||||
retain_until_ts bigint,
|
||||
created_ts bigint,
|
||||
updated_ts bigint,
|
||||
deleted uint
|
||||
) type='rt' morphology='lemmatize_de_all,stem_en'`, idx.table)
|
||||
if _, err := idx.db.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("manticore: ensureTable %s: %w", idx.table, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IndexSync upserts a document via REPLACE INTO (id-based, Manticore-typical).
|
||||
//
|
||||
// The two MVA (multi) columns tag_ids/acl_group_ids are interpolated inline
|
||||
// because Manticore does not accept bind placeholders inside the (a,b,c) MVA
|
||||
// value syntax. This is injection-safe: both lists are rendered from int64
|
||||
// values only (joinInts), never from free text.
|
||||
func (idx *manticoreIndex) IndexSync(ctx context.Context, doc DocumentDoc) error {
|
||||
_, err := idx.db.ExecContext(ctx,
|
||||
fmt.Sprintf(`REPLACE INTO %s
|
||||
(id, doc_id, title, doc_type, correspondent, ocr_text, tags, tag_ids, doc_type_id, correspondent_id, acl_group_ids, retain_until_ts, created_ts, updated_ts, deleted)
|
||||
VALUES (?,?,?,?,?,?,?,(%s),?,?,(%s),?,?,?,?)`, idx.table, joinInts(doc.TagIDs), joinInts(doc.ACLGroupIDs)),
|
||||
doc.ID,
|
||||
fmt.Sprintf("%d", doc.ID),
|
||||
doc.Title,
|
||||
doc.DocType,
|
||||
doc.Correspondent,
|
||||
doc.OCRText,
|
||||
strings.Join(doc.Tags, " "),
|
||||
ptrInt64(doc.DocTypeID),
|
||||
ptrInt64(doc.CorrespondentID),
|
||||
unixOrZero(doc.RetainUntil),
|
||||
doc.CreatedAt.Unix(),
|
||||
doc.UpdatedAt.Unix(),
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("manticore: IndexSync %s id=%d: %w", idx.table, doc.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a document from the RT index by its documents.id.
|
||||
func (idx *manticoreIndex) Delete(ctx context.Context, id int64) error {
|
||||
_, err := idx.db.ExecContext(ctx,
|
||||
fmt.Sprintf("DELETE FROM %s WHERE id = ?", idx.table), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("manticore: Delete %s id=%d: %w", idx.table, id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search runs a full-text + attribute query against the RT index and returns
|
||||
// the ranked hits for the requested page plus the overall match count.
|
||||
//
|
||||
// The full-text term (if any) is matched against the title, ocr_text, tags,
|
||||
// correspondent and doc_type fields. It is escaped via escapeMatch before it
|
||||
// is placed into a MATCH() expression — the only user-controlled string in the
|
||||
// query; every other filter value is an int64 rendered inline (injection-safe)
|
||||
// or a bound placeholder.
|
||||
func (idx *manticoreIndex) Search(ctx context.Context, q SearchQuery) ([]SearchHit, int, error) {
|
||||
var whereParts []string
|
||||
var args []any
|
||||
|
||||
hasMatch := strings.TrimSpace(q.Query) != ""
|
||||
if hasMatch {
|
||||
whereParts = append(whereParts, "MATCH(?)")
|
||||
args = append(args, "@(title,ocr_text,tags,correspondent,doc_type) "+escapeMatch(q.Query))
|
||||
}
|
||||
|
||||
// Never return purged documents.
|
||||
whereParts = append(whereParts, "deleted = 0")
|
||||
|
||||
// Attribute filters. MVA lists are rendered inline from int64 values only
|
||||
// (joinInts) — Manticore rejects placeholders inside ANY(...) IN (...).
|
||||
if len(q.TagIDs) > 0 {
|
||||
whereParts = append(whereParts, fmt.Sprintf("ANY(tag_ids) IN (%s)", joinInts(q.TagIDs)))
|
||||
}
|
||||
if q.DocTypeID != nil {
|
||||
whereParts = append(whereParts, "doc_type_id = ?")
|
||||
args = append(args, *q.DocTypeID)
|
||||
}
|
||||
if q.ACLGroupIDs != nil {
|
||||
// A non-nil but empty slice means "no visible groups" — match nothing.
|
||||
if len(q.ACLGroupIDs) == 0 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
whereParts = append(whereParts, fmt.Sprintf("ANY(acl_group_ids) IN (%s)", joinInts(q.ACLGroupIDs)))
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
if len(whereParts) > 0 {
|
||||
whereClause = "WHERE " + strings.Join(whereParts, " AND ")
|
||||
}
|
||||
|
||||
// Total match count (across all pages) for pagination metadata.
|
||||
countArgs := make([]any, len(args))
|
||||
copy(countArgs, args)
|
||||
countSQL := fmt.Sprintf("SELECT COUNT(*) FROM %s %s OPTION max_matches=1000000", idx.table, whereClause)
|
||||
var total int
|
||||
if err := idx.db.QueryRowContext(ctx, countSQL, countArgs...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("manticore: Search count %s: %w", idx.table, err)
|
||||
}
|
||||
|
||||
pageSize := q.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
page := q.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
scoreExpr := "1 as score"
|
||||
orderBy := "created_ts DESC"
|
||||
if hasMatch {
|
||||
scoreExpr = "WEIGHT() as score"
|
||||
orderBy = "WEIGHT() DESC, created_ts DESC"
|
||||
}
|
||||
|
||||
selectSQL := fmt.Sprintf(
|
||||
"SELECT id, %s FROM %s %s ORDER BY %s LIMIT ? OFFSET ? OPTION max_matches=10000",
|
||||
scoreExpr, idx.table, whereClause, orderBy)
|
||||
selectArgs := make([]any, len(args))
|
||||
copy(selectArgs, args)
|
||||
selectArgs = append(selectArgs, pageSize, offset)
|
||||
|
||||
rows, err := idx.db.QueryContext(ctx, selectSQL, selectArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("manticore: Search select %s: %w", idx.table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var hits []SearchHit
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var score float64
|
||||
if err := rows.Scan(&id, &score); err != nil {
|
||||
return nil, 0, fmt.Errorf("manticore: Search scan %s: %w", idx.table, err)
|
||||
}
|
||||
hits = append(hits, SearchHit{ID: id, Score: score})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("manticore: Search rows %s: %w", idx.table, err)
|
||||
}
|
||||
return hits, total, nil
|
||||
}
|
||||
|
||||
// escapeMatch escapes characters that carry special meaning in a Manticore
|
||||
// MATCH() expression, so a user-supplied full-text term can never inject
|
||||
// operators (query-injection guard). Mirrors the established archivmail
|
||||
// escapeManticoreMatch pattern.
|
||||
func escapeMatch(s string) string {
|
||||
const specials = `\()|!@~"/^$=<`
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, c := range s {
|
||||
if strings.ContainsRune(specials, c) {
|
||||
b.WriteRune('\\')
|
||||
}
|
||||
b.WriteRune(c)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// manticoreTableName returns the RT table name for a tenant. Panics on an
|
||||
// invalid result — that would be a programming error, not a runtime condition.
|
||||
func manticoreTableName(tenantID int64) string {
|
||||
name := fmt.Sprintf("documents_tenant_%d", tenantID)
|
||||
if !validTableName.MatchString(name) {
|
||||
panic(fmt.Sprintf("manticore: invalid table name: %q", name))
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// joinInts renders an int64 slice as a comma-separated list for a Manticore
|
||||
// multi (MVA) column value, wrapped in parentheses by the caller's placeholder.
|
||||
func joinInts(ids []int64) string {
|
||||
if len(ids) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, len(ids))
|
||||
for i, v := range ids {
|
||||
parts[i] = fmt.Sprintf("%d", v)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func ptrInt64(p *int64) int64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func unixOrZero(t *time.Time) int64 {
|
||||
if t == nil || t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
// noopIndexer is returned when a tenant table cannot be ensured. Every method
|
||||
// silently succeeds so a backend hiccup never blocks the calling request.
|
||||
type noopIndexer struct{}
|
||||
|
||||
func (noopIndexer) IndexSync(context.Context, DocumentDoc) error { return nil }
|
||||
func (noopIndexer) Delete(context.Context, int64) error { return nil }
|
||||
func (noopIndexer) Search(context.Context, SearchQuery) ([]SearchHit, int, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Package jobqueue ist die Mandanten-faire Arbeitswarteschlange für die
|
||||
// asynchrone Dokument-Nachverarbeitung (OCR, Taxonomie-Autozuordnung,
|
||||
// on_upload-Workflows).
|
||||
//
|
||||
// Architektur (bewusst schlank):
|
||||
//
|
||||
// - Backend: Postgres-Tabelle processing_jobs (internal/storage/
|
||||
// processing_jobs.go), KEIN Redis/AMQP. Job-Insert und documents-Insert
|
||||
// laufen in derselben Transaktion, damit nie ein Dokument ohne Job
|
||||
// entsteht.
|
||||
// - Worker: Goroutinen IM SELBEN Backend-Prozess, kein separater Dienst und
|
||||
// kein Container. Anzahl aus der Config (jobqueue.workers).
|
||||
// - Fairness: der Dispatcher arbeitet Round-Robin über die Mandanten. Pro
|
||||
// Runde wird je Mandant mit fälligen Jobs GENAU EINER gezogen
|
||||
// (ClaimNextJobForTenant), erst danach beginnt die nächste Runde. Ein
|
||||
// Mandant mit 5.000 Batch-Scans kann damit die Verarbeitung der anderen
|
||||
// Mandanten verzögern, aber nicht aushungern (globales FIFO würde genau
|
||||
// das tun).
|
||||
// - Locking: FOR UPDATE SKIP LOCKED, dadurch können beliebig viele Worker
|
||||
// (und theoretisch mehrere Prozesse) parallel ziehen, ohne dass ein Job
|
||||
// doppelt läuft.
|
||||
// - Reaper: hängengebliebene 'processing'-Jobs (Prozess-Neustart, toter
|
||||
// OCR-Subprozess) werden nach jobqueue.job_timeout_seconds zurückgesetzt
|
||||
// und mit exponentiellem Backoff neu eingeplant; ab max_retries bleiben
|
||||
// sie dauerhaft 'failed' (kein Automatik-Retry mehr, manueller Retry ist
|
||||
// Phase 3/Frontend).
|
||||
//
|
||||
// WORM bleibt außen vor: der Worker liest die archivierte Datei nur und
|
||||
// schreibt ausschließlich abgeleitete Metadaten.
|
||||
package jobqueue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"archivdms/config"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
// ProcessFunc verarbeitet einen einzelnen Job. Implementiert von
|
||||
// internal/api.(*Server).ProcessDocumentJob und als Funktionswert
|
||||
// hereingereicht (statt internal/api zu importieren) — dasselbe Muster wie
|
||||
// sftpserver.UploadFunc, um einen Import-Zyklus zu vermeiden. Die
|
||||
// Verdrahtung passiert in cmd/archivdms/main.go.
|
||||
type ProcessFunc func(ctx context.Context, tenantID, documentID int64, deriveTitle bool) error
|
||||
|
||||
// Dispatcher zieht Jobs Round-Robin über die Mandanten und verteilt sie an
|
||||
// einen Pool von Worker-Goroutinen.
|
||||
type Dispatcher struct {
|
||||
cfg config.JobQueueConfig
|
||||
store *storage.Store
|
||||
process ProcessFunc
|
||||
logger *slog.Logger
|
||||
|
||||
jobs chan *storage.ProcessingJob
|
||||
stopOnce sync.Once
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// New erzeugt einen Dispatcher. Start startet Worker, Dispatch-Loop und
|
||||
// Reaper; Stop fährt alles sauber herunter.
|
||||
func New(cfg config.JobQueueConfig, store *storage.Store, process ProcessFunc, logger *slog.Logger) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
process: process,
|
||||
logger: logger,
|
||||
// Ungepuffert: der Dispatch-Loop blockiert, solange alle Worker
|
||||
// beschäftigt sind. Genau erwünscht — so werden nur so viele Jobs auf
|
||||
// 'processing' gesetzt, wie auch tatsächlich gerade laufen können, und
|
||||
// ein Prozess-Neustart lässt keine unnötig große Menge Jobs im
|
||||
// Reaper-Timeout hängen.
|
||||
jobs: make(chan *storage.ProcessingJob),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start startet den Worker-Pool, den Round-Robin-Dispatch-Loop und den
|
||||
// Reaper. Nicht blockierend.
|
||||
func (d *Dispatcher) Start(ctx context.Context) {
|
||||
workers := d.cfg.ResolvedWorkers()
|
||||
for i := 0; i < workers; i++ {
|
||||
d.wg.Add(1)
|
||||
go d.worker(ctx, i+1)
|
||||
}
|
||||
d.wg.Add(2)
|
||||
go d.dispatchLoop(ctx)
|
||||
go d.reapLoop(ctx)
|
||||
|
||||
d.logger.Info("job queue started",
|
||||
"workers", workers,
|
||||
"poll_interval", d.cfg.ResolvedPollInterval(),
|
||||
"job_timeout", d.cfg.ResolvedJobTimeout(),
|
||||
"max_retries", d.cfg.ResolvedMaxRetries())
|
||||
}
|
||||
|
||||
// Stop signalisiert allen Goroutinen das Ende und wartet auf sie.
|
||||
func (d *Dispatcher) Stop() {
|
||||
d.stopOnce.Do(func() { close(d.stopCh) })
|
||||
d.wg.Wait()
|
||||
}
|
||||
|
||||
// dispatchLoop pollt die Queue und verteilt Jobs Round-Robin über Mandanten.
|
||||
func (d *Dispatcher) dispatchLoop(ctx context.Context) {
|
||||
defer d.wg.Done()
|
||||
ticker := time.NewTicker(d.cfg.ResolvedPollInterval())
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-d.stopCh:
|
||||
close(d.jobs)
|
||||
return
|
||||
case <-ctx.Done():
|
||||
close(d.jobs)
|
||||
return
|
||||
case <-ticker.C:
|
||||
d.dispatchRound(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchRound führt so lange Round-Robin-Runden aus, wie noch Mandanten
|
||||
// mit fälligen Jobs übrig sind. Pro Runde wird je Mandant genau ein Job
|
||||
// gezogen und an den Worker-Pool übergeben — dadurch wechseln sich die
|
||||
// Mandanten ab, statt dass Mandant A komplett leergeräumt wird, bevor
|
||||
// Mandant B drankommt.
|
||||
func (d *Dispatcher) dispatchRound(ctx context.Context) {
|
||||
for {
|
||||
tenants, err := d.store.TenantsWithDueJobs(ctx)
|
||||
if err != nil {
|
||||
d.logger.Warn("job queue: listing tenants with due jobs failed", "err", err)
|
||||
return
|
||||
}
|
||||
if len(tenants) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
dispatched := 0
|
||||
for _, tenantID := range tenants {
|
||||
select {
|
||||
case <-d.stopCh:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
job, err := d.store.ClaimNextJobForTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrNoJob) {
|
||||
continue // Runde hat sich zwischenzeitlich erledigt
|
||||
}
|
||||
d.logger.Warn("job queue: claim failed", "tenant_id", tenantID, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case d.jobs <- job:
|
||||
dispatched++
|
||||
case <-d.stopCh:
|
||||
// Beim Herunterfahren den bereits geclaimten Job nicht
|
||||
// verlieren: sofort wieder einreihen (retry_count bleibt
|
||||
// unangetastet), sonst müsste erst der Reaper-Timeout
|
||||
// ablaufen.
|
||||
if rerr := d.store.RequeueJob(context.Background(), job.ID, job.TenantID); rerr != nil {
|
||||
d.logger.Warn("job queue: requeue on shutdown failed", "job_id", job.ID, "err", rerr)
|
||||
}
|
||||
return
|
||||
case <-ctx.Done():
|
||||
if rerr := d.store.RequeueJob(context.Background(), job.ID, job.TenantID); rerr != nil {
|
||||
d.logger.Warn("job queue: requeue on shutdown failed", "job_id", job.ID, "err", rerr)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if dispatched == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// worker verarbeitet Jobs aus dem Kanal, einer nach dem anderen.
|
||||
func (d *Dispatcher) worker(ctx context.Context, num int) {
|
||||
defer d.wg.Done()
|
||||
for job := range d.jobs {
|
||||
d.runJob(ctx, num, job)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) runJob(ctx context.Context, worker int, job *storage.ProcessingJob) {
|
||||
started := time.Now()
|
||||
// Eigener Timeout je Job, damit ein hängender OCR-Subprozess einen Worker
|
||||
// nicht dauerhaft belegt. Bewusst NICHT vom Request-Kontext abgeleitet —
|
||||
// die Verarbeitung ist vom Upload-Request entkoppelt.
|
||||
jobCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), d.cfg.ResolvedJobTimeout())
|
||||
defer cancel()
|
||||
|
||||
err := d.process(jobCtx, job.TenantID, job.DocumentID, job.DeriveTitle)
|
||||
if err != nil {
|
||||
requeued, mErr := d.store.MarkJobFailed(context.WithoutCancel(ctx), job.ID, job.TenantID, job.DocumentID, err.Error(), d.cfg.ResolvedMaxRetries())
|
||||
if mErr != nil {
|
||||
d.logger.Error("job queue: recording job failure failed", "job_id", job.ID, "err", mErr)
|
||||
}
|
||||
d.logger.Warn("job queue: job failed",
|
||||
"worker", worker, "job_id", job.ID, "tenant_id", job.TenantID, "document_id", job.DocumentID,
|
||||
"retry_count", job.RetryCount, "will_retry", requeued, "duration", time.Since(started), "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := d.store.MarkJobDone(context.WithoutCancel(ctx), job.ID, job.TenantID, job.DocumentID); err != nil {
|
||||
d.logger.Error("job queue: marking job done failed", "job_id", job.ID, "err", err)
|
||||
return
|
||||
}
|
||||
d.logger.Info("job queue: job done",
|
||||
"worker", worker, "job_id", job.ID, "tenant_id", job.TenantID, "document_id", job.DocumentID,
|
||||
"duration", time.Since(started))
|
||||
}
|
||||
|
||||
// reapLoop setzt regelmäßig hängengebliebene 'processing'-Jobs zurück.
|
||||
func (d *Dispatcher) reapLoop(ctx context.Context) {
|
||||
defer d.wg.Done()
|
||||
interval := d.cfg.ResolvedJobTimeout() / 2
|
||||
if interval < 5*time.Second {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-d.stopCh:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
n, err := d.store.ReapStaleJobs(ctx, d.cfg.ResolvedJobTimeout(), d.cfg.ResolvedMaxRetries())
|
||||
if err != nil {
|
||||
d.logger.Warn("job queue: reaper failed", "err", err)
|
||||
continue
|
||||
}
|
||||
if n > 0 {
|
||||
d.logger.Warn("job queue: reset stale processing jobs", "count", n, "timeout", d.cfg.ResolvedJobTimeout())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// String beschreibt die aktive Konfiguration (Diagnose-/Log-Hilfe).
|
||||
func (d *Dispatcher) String() string {
|
||||
return fmt.Sprintf("jobqueue(workers=%d poll=%s timeout=%s max_retries=%d)",
|
||||
d.cfg.ResolvedWorkers(), d.cfg.ResolvedPollInterval(), d.cfg.ResolvedJobTimeout(), d.cfg.ResolvedMaxRetries())
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Package ldapauth implements the LDAP bind/search authentication flow against
|
||||
// a per-tenant directory (config from internal/ldapstore). It uses
|
||||
// github.com/go-ldap/ldap/v3 (pure Go, CGO_ENABLED=0 compatible).
|
||||
//
|
||||
// Flow (Authenticate):
|
||||
// 1. Connect over LDAPS or StartTLS (cleartext LDAP is rejected).
|
||||
// 2. Service-bind with bind_dn + decrypted bind password.
|
||||
// 3. Search base_dn with user_filter, loginName escaped per RFC 4515 to
|
||||
// prevent LDAP filter injection; expect exactly one entry.
|
||||
// 4. Re-bind as the found user DN with the user-supplied password
|
||||
// (this is the actual credential check — no fallback to a local password).
|
||||
// 5. Optionally search the group tree to decide admin group membership,
|
||||
// which the caller maps to a role.
|
||||
package ldapauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
|
||||
"archivdms/internal/ldapstore"
|
||||
)
|
||||
|
||||
// Result is the outcome of a successful authentication.
|
||||
type Result struct {
|
||||
// Username is the attr_username value from the directory (used as the
|
||||
// local username / ldap_uid on JIT provisioning).
|
||||
Username string
|
||||
// Email is the attr_email value.
|
||||
Email string
|
||||
// DisplayName is the attr_name value.
|
||||
DisplayName string
|
||||
// UserDN is the distinguished name the user bound with.
|
||||
UserDN string
|
||||
// IsAdmin is true when admin_group_dn is configured and the user is a
|
||||
// member of it — mapped by the caller to domain_admin.
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// Authenticator performs LDAP authentication. It is stateless apart from a
|
||||
// dial timeout, so a single instance can be shared across requests.
|
||||
type Authenticator struct {
|
||||
dialTimeout time.Duration
|
||||
}
|
||||
|
||||
// New returns an Authenticator with the given dial/connect timeout (<=0 uses
|
||||
// a 10s default).
|
||||
func New(dialTimeout time.Duration) *Authenticator {
|
||||
if dialTimeout <= 0 {
|
||||
dialTimeout = 10 * time.Second
|
||||
}
|
||||
return &Authenticator{dialTimeout: dialTimeout}
|
||||
}
|
||||
|
||||
// connect opens a TLS-protected LDAP connection according to cfg.UseTLS.
|
||||
func (a *Authenticator) connect(cfg *ldapstore.Config) (*ldap.Conn, error) {
|
||||
tlsCfg := &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
dialer := &net.Dialer{Timeout: a.dialTimeout}
|
||||
|
||||
switch cfg.UseTLS {
|
||||
case ldapstore.TLSModeLDAPS:
|
||||
conn, err := ldap.DialURL("ldaps://"+addr, ldap.DialWithTLSConfig(tlsCfg), ldap.DialWithDialer(dialer))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldapauth: dial ldaps: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
case ldapstore.TLSModeStartTLS:
|
||||
conn, err := ldap.DialURL("ldap://"+addr, ldap.DialWithDialer(dialer))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldapauth: dial ldap: %w", err)
|
||||
}
|
||||
if err := conn.StartTLS(tlsCfg); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("ldapauth: starttls: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("ldapauth: cleartext LDAP not permitted (use_tls=%q)", cfg.UseTLS)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnection performs only the service-bind and a base-DN search — it does
|
||||
// NOT attempt a user login. Returns the round-trip latency.
|
||||
func (a *Authenticator) TestConnection(ctx context.Context, cfg *ldapstore.Config, bindPassword string) (time.Duration, error) {
|
||||
start := time.Now()
|
||||
conn, err := a.connect(cfg)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer conn.Close()
|
||||
conn.SetTimeout(a.dialTimeout)
|
||||
|
||||
if err := conn.Bind(cfg.BindDN, bindPassword); err != nil {
|
||||
return 0, fmt.Errorf("ldapauth: service bind failed: %w", err)
|
||||
}
|
||||
// Minimal base-scope search to confirm base_dn is reachable/valid.
|
||||
req := ldap.NewSearchRequest(
|
||||
cfg.BaseDN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, int(a.dialTimeout.Seconds()), false,
|
||||
"(objectClass=*)", []string{"dn"}, nil,
|
||||
)
|
||||
if _, err := conn.Search(req); err != nil {
|
||||
return 0, fmt.Errorf("ldapauth: base search failed: %w", err)
|
||||
}
|
||||
return time.Since(start), nil
|
||||
}
|
||||
|
||||
// Authenticate runs the full bind/search/re-bind flow.
|
||||
func (a *Authenticator) Authenticate(ctx context.Context, cfg *ldapstore.Config, bindPassword, loginName, userPassword string) (*Result, error) {
|
||||
if userPassword == "" {
|
||||
// Prevent LDAP "unauthenticated bind" (empty password = anonymous success).
|
||||
return nil, fmt.Errorf("ldapauth: empty password")
|
||||
}
|
||||
conn, err := a.connect(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
conn.SetTimeout(a.dialTimeout)
|
||||
|
||||
// 1) Service bind.
|
||||
if err := conn.Bind(cfg.BindDN, bindPassword); err != nil {
|
||||
return nil, fmt.Errorf("ldapauth: service bind failed: %w", err)
|
||||
}
|
||||
|
||||
// 2) Search for the user. loginName escaped against filter injection.
|
||||
filter := strings.ReplaceAll(cfg.UserFilter, "%s", ldap.EscapeFilter(loginName))
|
||||
attrs := []string{"dn", cfg.AttrUsername, cfg.AttrEmail, cfg.AttrName}
|
||||
searchReq := ldap.NewSearchRequest(
|
||||
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 2, int(a.dialTimeout.Seconds()), false,
|
||||
filter, attrs, nil,
|
||||
)
|
||||
sr, err := conn.Search(searchReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldapauth: user search failed: %w", err)
|
||||
}
|
||||
if len(sr.Entries) == 0 {
|
||||
return nil, fmt.Errorf("ldapauth: user not found")
|
||||
}
|
||||
if len(sr.Entries) > 1 {
|
||||
return nil, fmt.Errorf("ldapauth: user filter not unique (%d entries)", len(sr.Entries))
|
||||
}
|
||||
entry := sr.Entries[0]
|
||||
|
||||
res := &Result{
|
||||
UserDN: entry.DN,
|
||||
Username: firstNonEmpty(entry.GetAttributeValue(cfg.AttrUsername), loginName),
|
||||
Email: entry.GetAttributeValue(cfg.AttrEmail),
|
||||
DisplayName: entry.GetAttributeValue(cfg.AttrName),
|
||||
}
|
||||
|
||||
// 3) Re-bind as the user to verify the password.
|
||||
if err := conn.Bind(entry.DN, userPassword); err != nil {
|
||||
return nil, fmt.Errorf("ldapauth: invalid credentials")
|
||||
}
|
||||
|
||||
// 4) Group membership for admin role mapping. Re-bind as service account
|
||||
// first (the user account may lack read rights on the group tree).
|
||||
if cfg.AdminGroupDN != "" {
|
||||
if err := conn.Bind(cfg.BindDN, bindPassword); err != nil {
|
||||
return nil, fmt.Errorf("ldapauth: re-bind for group search failed: %w", err)
|
||||
}
|
||||
isAdmin, err := a.isAdminMember(conn, cfg, res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.IsAdmin = isAdmin
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// isAdminMember checks whether the authenticated user belongs to admin_group_dn.
|
||||
// Two strategies are supported:
|
||||
// - group_base_dn + group_filter set: search the group tree with a filter
|
||||
// where %s is replaced by the user DN (escaped), then check whether the
|
||||
// admin_group_dn is among the returned group DNs.
|
||||
// - otherwise: a base-scope search of admin_group_dn testing the standard
|
||||
// member/uniqueMember/memberUid attributes against the user.
|
||||
func (a *Authenticator) isAdminMember(conn *ldap.Conn, cfg *ldapstore.Config, res *Result) (bool, error) {
|
||||
if cfg.GroupBaseDN != "" && cfg.GroupFilter != "" {
|
||||
filter := cfg.GroupFilter
|
||||
filter = strings.ReplaceAll(filter, "%d", ldap.EscapeFilter(res.UserDN))
|
||||
filter = strings.ReplaceAll(filter, "%s", ldap.EscapeFilter(res.Username))
|
||||
req := ldap.NewSearchRequest(
|
||||
cfg.GroupBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, int(a.dialTimeout.Seconds()), false,
|
||||
filter, []string{"dn"}, nil,
|
||||
)
|
||||
sr, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("ldapauth: group search failed: %w", err)
|
||||
}
|
||||
for _, e := range sr.Entries {
|
||||
if strings.EqualFold(strings.TrimSpace(e.DN), strings.TrimSpace(cfg.AdminGroupDN)) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Fallback: inspect the admin group entry directly.
|
||||
req := ldap.NewSearchRequest(
|
||||
cfg.AdminGroupDN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, int(a.dialTimeout.Seconds()), false,
|
||||
"(objectClass=*)", []string{"member", "uniqueMember", "memberUid"}, nil,
|
||||
)
|
||||
sr, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("ldapauth: admin group lookup failed: %w", err)
|
||||
}
|
||||
if len(sr.Entries) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
e := sr.Entries[0]
|
||||
for _, dn := range append(e.GetAttributeValues("member"), e.GetAttributeValues("uniqueMember")...) {
|
||||
if strings.EqualFold(strings.TrimSpace(dn), strings.TrimSpace(res.UserDN)) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
for _, uid := range e.GetAttributeValues("memberUid") {
|
||||
if strings.EqualFold(strings.TrimSpace(uid), strings.TrimSpace(res.Username)) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// Package ldapstore is a PostgreSQL-backed CRUD store for per-tenant LDAP
|
||||
// directory configuration (ldap_configs table). It follows the same
|
||||
// Store-per-schema pattern as userstore/tenantstore: initSchema() is idempotent
|
||||
// and called from New().
|
||||
//
|
||||
// The LDAP service-bind password is never stored in plaintext: it is encrypted
|
||||
// with AES-256-GCM via internal/cryptutil (key derived from the application
|
||||
// master secret) and stored as ciphertext + nonce. Get() returns the config
|
||||
// WITHOUT the password (only HasBindPassword); GetWithSecret() decrypts it for
|
||||
// the actual bind at login time.
|
||||
package ldapstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"archivdms/internal/cryptutil"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when no ldap_configs row exists for a tenant.
|
||||
var ErrNotFound = errors.New("ldapstore: config not found")
|
||||
|
||||
// TLS mode values for Config.UseTLS.
|
||||
const (
|
||||
TLSModeLDAPS = "ldaps"
|
||||
TLSModeStartTLS = "starttls"
|
||||
)
|
||||
|
||||
// Config mirrors a row of ldap_configs, minus the encrypted password columns.
|
||||
// HasBindPassword reports whether a bind password is stored (surfaced to the
|
||||
// API as "is_set"); the plaintext is only ever available via GetWithSecret.
|
||||
type Config struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
UseTLS string `json:"use_tls"`
|
||||
BindDN string `json:"bind_dn"`
|
||||
HasBindPassword bool `json:"bind_password_set"`
|
||||
BaseDN string `json:"base_dn"`
|
||||
UserFilter string `json:"user_filter"`
|
||||
AttrUsername string `json:"attr_username"`
|
||||
AttrEmail string `json:"attr_email"`
|
||||
AttrName string `json:"attr_name"`
|
||||
GroupBaseDN string `json:"group_base_dn"`
|
||||
GroupFilter string `json:"group_filter"`
|
||||
AdminGroupDN string `json:"admin_group_dn"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Store is a PostgreSQL-backed LDAP config store.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
box *cryptutil.Box
|
||||
}
|
||||
|
||||
// New connects to PostgreSQL, initialises the schema, and derives the
|
||||
// password-encryption key from secret (the application master/JWT secret).
|
||||
func New(dsn, secret string) (*Store, error) {
|
||||
ctx := context.Background()
|
||||
box, err := cryptutil.NewBox(secret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldapstore: crypto init: %w", err)
|
||||
}
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldapstore: connect: %w", err)
|
||||
}
|
||||
s := &Store{pool: pool, box: box}
|
||||
if err := s.initSchema(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ldapstore: init schema: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// initSchema creates ldap_configs and adds the LDAP columns to users.
|
||||
// Idempotent. Documented in migrations/011_ldap.sql.
|
||||
//
|
||||
// No FK on tenant_id: consistent with the rest of the schema (documents /
|
||||
// permissions use a plain BIGINT tenant_id) and required because tenants/users
|
||||
// are created by other stores whose init order relative to this one is not
|
||||
// guaranteed (see cmd/archivdms/main.go).
|
||||
func (s *Store) initSchema(ctx context.Context) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS ldap_configs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL UNIQUE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
host VARCHAR(255) NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 636,
|
||||
use_tls VARCHAR(20) NOT NULL DEFAULT 'ldaps',
|
||||
bind_dn VARCHAR(500) NOT NULL,
|
||||
bind_password_enc BYTEA NOT NULL,
|
||||
bind_password_nonce BYTEA NOT NULL,
|
||||
base_dn VARCHAR(500) NOT NULL,
|
||||
user_filter VARCHAR(500) NOT NULL DEFAULT '(uid=%s)',
|
||||
attr_username VARCHAR(100) NOT NULL DEFAULT 'uid',
|
||||
attr_email VARCHAR(100) NOT NULL DEFAULT 'mail',
|
||||
attr_name VARCHAR(100) NOT NULL DEFAULT 'cn',
|
||||
group_base_dn VARCHAR(500),
|
||||
group_filter VARCHAR(500),
|
||||
admin_group_dn VARCHAR(500),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS auth_source VARCHAR(20) NOT NULL DEFAULT 'local';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS ldap_uid VARCHAR(255);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS ldap_synced_at TIMESTAMPTZ;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_tenant_ldap_uid ON users (tenant_id, ldap_uid) WHERE ldap_uid IS NOT NULL;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// Close closes the underlying connection pool.
|
||||
func (s *Store) Close() error {
|
||||
s.pool.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
const selectCols = `id, tenant_id, enabled, host, port, use_tls, bind_dn,
|
||||
(octet_length(bind_password_enc) > 0) AS has_pw,
|
||||
base_dn, user_filter, attr_username, attr_email, attr_name,
|
||||
COALESCE(group_base_dn, ''), COALESCE(group_filter, ''), COALESCE(admin_group_dn, ''),
|
||||
created_at, updated_at`
|
||||
|
||||
func scanConfig(row pgx.Row) (*Config, error) {
|
||||
var c Config
|
||||
err := row.Scan(
|
||||
&c.ID, &c.TenantID, &c.Enabled, &c.Host, &c.Port, &c.UseTLS, &c.BindDN,
|
||||
&c.HasBindPassword, &c.BaseDN, &c.UserFilter, &c.AttrUsername, &c.AttrEmail, &c.AttrName,
|
||||
&c.GroupBaseDN, &c.GroupFilter, &c.AdminGroupDN, &c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldapstore: scan: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// Get returns the LDAP config for a tenant WITHOUT the bind password.
|
||||
func (s *Store) Get(ctx context.Context, tenantID int64) (*Config, error) {
|
||||
row := s.pool.QueryRow(ctx, `SELECT `+selectCols+` FROM ldap_configs WHERE tenant_id = $1`, tenantID)
|
||||
return scanConfig(row)
|
||||
}
|
||||
|
||||
// GetWithSecret returns the LDAP config together with the decrypted bind
|
||||
// password. Only used at login/test time — never surfaced to API responses.
|
||||
func (s *Store) GetWithSecret(ctx context.Context, tenantID int64) (*Config, string, error) {
|
||||
cfg, err := s.Get(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var enc, nonce []byte
|
||||
err = s.pool.QueryRow(ctx,
|
||||
`SELECT bind_password_enc, bind_password_nonce FROM ldap_configs WHERE tenant_id = $1`, tenantID,
|
||||
).Scan(&enc, &nonce)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("ldapstore: read secret: %w", err)
|
||||
}
|
||||
pw, err := s.box.Decrypt(enc, nonce)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("ldapstore: decrypt bind password: %w", err)
|
||||
}
|
||||
return cfg, string(pw), nil
|
||||
}
|
||||
|
||||
// Upsert creates or updates the LDAP config for cfg.TenantID.
|
||||
//
|
||||
// newPassword semantics:
|
||||
// - non-nil: the bind password is (re)encrypted and stored.
|
||||
// - nil on an existing row: the stored password is kept unchanged.
|
||||
// - nil on a new row: an error is returned (a bind password is mandatory).
|
||||
func (s *Store) Upsert(ctx context.Context, cfg Config, newPassword *string) (*Config, error) {
|
||||
if cfg.UseTLS != TLSModeLDAPS && cfg.UseTLS != TLSModeStartTLS {
|
||||
return nil, fmt.Errorf("ldapstore: use_tls must be %q or %q (cleartext LDAP not permitted)", TLSModeLDAPS, TLSModeStartTLS)
|
||||
}
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 636
|
||||
}
|
||||
|
||||
// Determine the password bytes to store.
|
||||
var enc, nonce []byte
|
||||
_, existing, existErr := s.GetWithSecret(ctx, cfg.TenantID)
|
||||
switch {
|
||||
case newPassword != nil:
|
||||
var err error
|
||||
enc, nonce, err = s.box.Encrypt([]byte(*newPassword))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldapstore: encrypt bind password: %w", err)
|
||||
}
|
||||
case existErr == nil:
|
||||
// Keep the existing password — re-encrypt to get fresh bytes.
|
||||
var err error
|
||||
enc, nonce, err = s.box.Encrypt([]byte(existing))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldapstore: re-encrypt bind password: %w", err)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("ldapstore: bind password required for new config")
|
||||
}
|
||||
|
||||
nullable := func(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO ldap_configs
|
||||
(tenant_id, enabled, host, port, use_tls, bind_dn, bind_password_enc, bind_password_nonce,
|
||||
base_dn, user_filter, attr_username, attr_email, attr_name,
|
||||
group_base_dn, group_filter, admin_group_dn, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16, NOW(), NOW())
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled,
|
||||
host = EXCLUDED.host,
|
||||
port = EXCLUDED.port,
|
||||
use_tls = EXCLUDED.use_tls,
|
||||
bind_dn = EXCLUDED.bind_dn,
|
||||
bind_password_enc = EXCLUDED.bind_password_enc,
|
||||
bind_password_nonce = EXCLUDED.bind_password_nonce,
|
||||
base_dn = EXCLUDED.base_dn,
|
||||
user_filter = EXCLUDED.user_filter,
|
||||
attr_username = EXCLUDED.attr_username,
|
||||
attr_email = EXCLUDED.attr_email,
|
||||
attr_name = EXCLUDED.attr_name,
|
||||
group_base_dn = EXCLUDED.group_base_dn,
|
||||
group_filter = EXCLUDED.group_filter,
|
||||
admin_group_dn = EXCLUDED.admin_group_dn,
|
||||
updated_at = NOW()`,
|
||||
cfg.TenantID, cfg.Enabled, cfg.Host, cfg.Port, cfg.UseTLS, cfg.BindDN, enc, nonce,
|
||||
cfg.BaseDN, cfg.UserFilter, cfg.AttrUsername, cfg.AttrEmail, cfg.AttrName,
|
||||
nullable(cfg.GroupBaseDN), nullable(cfg.GroupFilter), nullable(cfg.AdminGroupDN),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldapstore: upsert: %w", err)
|
||||
}
|
||||
return s.Get(ctx, cfg.TenantID)
|
||||
}
|
||||
|
||||
// Delete removes the LDAP config for a tenant. Returns ErrNotFound if absent.
|
||||
func (s *Store) Delete(ctx context.Context, tenantID int64) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM ldap_configs WHERE tenant_id = $1`, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ldapstore: delete: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Package llm is a minimal HTTP client for an EXTERNAL, already-running Ollama
|
||||
// server (never installed on the archivdms host — the base URL is provided per
|
||||
// tenant, see internal/storage/ollama_config.go). It intentionally does no
|
||||
// retrying, no connection pooling and no streaming: a single direct call to
|
||||
// Ollama's /api/generate endpoint, CGO-free, net/http only.
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// generateRequest is the JSON body POSTed to <base_url>/api/generate. stream is
|
||||
// always false (we want the whole answer at once) and format is "json" so the
|
||||
// model is nudged to emit valid JSON in the response field.
|
||||
type generateRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Stream bool `json:"stream"`
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
// generateResponse is the (non-streaming) envelope Ollama returns; the actual
|
||||
// model output is the Response string, which — because we requested
|
||||
// format=json — is itself a JSON document the caller parses structurally.
|
||||
type generateResponse struct {
|
||||
Response string `json:"response"`
|
||||
Done bool `json:"done"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// tagsResponse is the JSON envelope Ollama returns from GET /api/tags: a list
|
||||
// of the models installed on that server. Only the name is consumed here.
|
||||
type tagsResponse struct {
|
||||
Models []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"models"`
|
||||
}
|
||||
|
||||
// ListModels performs a single blocking GET /api/tags call against the given
|
||||
// Ollama base URL and returns the names of the models installed on that server,
|
||||
// so the frontend can offer a picklist instead of a free-text model field. Any
|
||||
// network error, timeout or non-200 status yields a clear error — there is NO
|
||||
// silent fallback. The returned slice is always non-nil (make, never nil) so it
|
||||
// JSON-encodes as [] rather than null.
|
||||
func ListModels(ctx context.Context, baseURL string, timeout time.Duration) ([]string, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("llm: ollama base_url is empty")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, baseURL+"/api/tags", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: build tags request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: ollama tags request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: read ollama tags response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("llm: ollama returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
|
||||
var env tagsResponse
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return nil, fmt.Errorf("llm: parse ollama tags envelope: %w", err)
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(env.Models))
|
||||
for _, m := range env.Models {
|
||||
if name := strings.TrimSpace(m.Name); name != "" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GenerateJSON performs a single blocking /api/generate call against the given
|
||||
// Ollama base URL and returns the model's inner `response` field as a
|
||||
// json.RawMessage (the caller unmarshals it into its own schema). Any network
|
||||
// error, timeout, non-200 status, Ollama-reported error or empty/invalid outer
|
||||
// response yields a clear error — there is NO silent fallback, so the caller
|
||||
// can report to the frontend exactly that Ollama did not answer.
|
||||
func GenerateJSON(ctx context.Context, baseURL, model string, timeout time.Duration, prompt string) (json.RawMessage, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("llm: ollama base_url is empty")
|
||||
}
|
||||
if model == "" {
|
||||
return nil, fmt.Errorf("llm: ollama model is empty")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
body, err := json.Marshal(generateRequest{
|
||||
Model: model,
|
||||
Prompt: prompt,
|
||||
Stream: false,
|
||||
Format: "json",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: marshal request: %w", err)
|
||||
}
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, baseURL+"/api/generate", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: ollama request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Cap the read so a misbehaving/unexpected endpoint cannot exhaust memory.
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: read ollama response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("llm: ollama returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
|
||||
var env generateResponse
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return nil, fmt.Errorf("llm: parse ollama envelope: %w", err)
|
||||
}
|
||||
if env.Error != "" {
|
||||
return nil, fmt.Errorf("llm: ollama error: %s", env.Error)
|
||||
}
|
||||
inner := strings.TrimSpace(env.Response)
|
||||
if inner == "" {
|
||||
return nil, fmt.Errorf("llm: ollama returned an empty response")
|
||||
}
|
||||
if !json.Valid([]byte(inner)) {
|
||||
return nil, fmt.Errorf("llm: ollama response field is not valid JSON")
|
||||
}
|
||||
return json.RawMessage(inner), nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Package mailer sends transactional emails via an outbound SMTP relay,
|
||||
// ported 1:1 from archivmail's internal/mailer (it has no mail-archiving
|
||||
// specifics — it is a generic outbound SMTP client used for reminder
|
||||
// notifications, invites, and password resets).
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"archivdms/config"
|
||||
)
|
||||
|
||||
// Mailer sends transactional emails via the configured SMTP-Out relay.
|
||||
type Mailer struct {
|
||||
mu sync.RWMutex
|
||||
cfg config.SMTPOutConfig
|
||||
}
|
||||
|
||||
// New creates a Mailer from the smtp_out config section.
|
||||
func New(cfg config.SMTPOutConfig) *Mailer {
|
||||
return &Mailer{cfg: cfg}
|
||||
}
|
||||
|
||||
// Reload replaces the runtime configuration without restarting the process.
|
||||
func (m *Mailer) Reload(cfg config.SMTPOutConfig) {
|
||||
m.mu.Lock()
|
||||
m.cfg = cfg
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// IsConfigured returns true when the smtp_out config is usable.
|
||||
func (m *Mailer) IsConfigured() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.cfg.Host != "" && m.cfg.From != ""
|
||||
}
|
||||
|
||||
// Send sends an HTML + plaintext email to a single recipient.
|
||||
func (m *Mailer) Send(to, subject, htmlBody, textBody string) error {
|
||||
m.mu.RLock()
|
||||
cfg := m.cfg
|
||||
m.mu.RUnlock()
|
||||
|
||||
if cfg.Host == "" || cfg.From == "" {
|
||||
return fmt.Errorf("mailer: smtp_out not configured")
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, port(cfg.Port))
|
||||
msg := buildMIME(cfg.From, to, subject, htmlBody, textBody)
|
||||
|
||||
var auth smtp.Auth
|
||||
if cfg.User != "" {
|
||||
auth = smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host)
|
||||
}
|
||||
|
||||
if cfg.TLS {
|
||||
return sendTLS(addr, cfg.Host, auth, cfg.From, to, msg)
|
||||
}
|
||||
return sendSTARTTLS(addr, auth, cfg.From, to, msg)
|
||||
}
|
||||
|
||||
func port(p int) int {
|
||||
if p == 0 {
|
||||
return 587
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func sendTLS(addr, host string, auth smtp.Auth, from, to string, msg []byte) error {
|
||||
tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
||||
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailer: tls dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
c, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailer: smtp client: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if auth != nil {
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("mailer: auth: %w", err)
|
||||
}
|
||||
}
|
||||
return send(c, from, to, msg)
|
||||
}
|
||||
|
||||
func sendSTARTTLS(addr string, auth smtp.Auth, from, to string, msg []byte) error {
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailer: dial: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
host, _, _ := net.SplitHostPort(addr)
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
||||
if err := c.StartTLS(tlsCfg); err != nil {
|
||||
return fmt.Errorf("mailer: starttls: %w", err)
|
||||
}
|
||||
}
|
||||
if auth != nil {
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("mailer: auth: %w", err)
|
||||
}
|
||||
}
|
||||
return send(c, from, to, msg)
|
||||
}
|
||||
|
||||
func send(c *smtp.Client, from, to string, msg []byte) error {
|
||||
if err := c.Mail(from); err != nil {
|
||||
return fmt.Errorf("mailer: MAIL FROM: %w", err)
|
||||
}
|
||||
if err := c.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("mailer: RCPT TO: %w", err)
|
||||
}
|
||||
wc, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailer: DATA: %w", err)
|
||||
}
|
||||
defer wc.Close()
|
||||
if _, err := wc.Write(msg); err != nil {
|
||||
return fmt.Errorf("mailer: write: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildMIME(from, to, subject, htmlBody, textBody string) []byte {
|
||||
boundary := "----=archivdms_boundary_20260101"
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("From: " + from + "\r\n")
|
||||
b.WriteString("To: " + to + "\r\n")
|
||||
b.WriteString("Subject: " + subject + "\r\n")
|
||||
b.WriteString("MIME-Version: 1.0\r\n")
|
||||
b.WriteString(`Content-Type: multipart/alternative; boundary="` + boundary + `"` + "\r\n")
|
||||
b.WriteString("\r\n")
|
||||
|
||||
b.WriteString("--" + boundary + "\r\n")
|
||||
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
b.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(textBody + "\r\n")
|
||||
|
||||
b.WriteString("--" + boundary + "\r\n")
|
||||
b.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
|
||||
b.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(htmlBody + "\r\n")
|
||||
|
||||
b.WriteString("--" + boundary + "--\r\n")
|
||||
return []byte(b.String())
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package mailer
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ReminderDueTemplate renders subject/HTML/text bodies for a reminder
|
||||
// ("Wiedervorlage") that has reached its due date. Used by the
|
||||
// `archivdms reminders notify` cron subcommand.
|
||||
func ReminderDueTemplate(documentTitle, note, dueDate, appURL string) (subject, html, text string) {
|
||||
subject = fmt.Sprintf("Wiedervorlage fällig: %s", documentTitle)
|
||||
|
||||
html = fmt.Sprintf(`<p>Eine Wiedervorlage ist fällig:</p>
|
||||
<p><strong>Dokument:</strong> %s<br>
|
||||
<strong>Fällig am:</strong> %s</p>
|
||||
<p>%s</p>
|
||||
<p><a href="%s">Zum Dokument</a></p>`, documentTitle, dueDate, note, appURL)
|
||||
|
||||
text = fmt.Sprintf("Eine Wiedervorlage ist fällig:\nDokument: %s\nFällig am: %s\n%s\n\nZum Dokument: %s",
|
||||
documentTitle, dueDate, note, appURL)
|
||||
return subject, html, text
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// Package matching implements the classification-matching algorithms used
|
||||
// by tags/document_types/correspondents (internal/storage/taxonomy.go) to
|
||||
// auto-assign themselves to a newly ingested document based on its OCR text
|
||||
// plus title. Deliberately dependency-free — no fuzzy-matching Go module is
|
||||
// added (project style: avoid unnecessary deps, see internal/ocr package
|
||||
// comment) — the fuzzy algorithm is a small self-contained normalized
|
||||
// Levenshtein ratio.
|
||||
package matching
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Supported algorithm names, mirrored by the match_algorithm CHECK
|
||||
// constraint on tags/document_types/correspondents.
|
||||
const (
|
||||
AlgorithmNone = "none"
|
||||
AlgorithmAny = "any"
|
||||
AlgorithmAll = "all"
|
||||
AlgorithmExact = "exact"
|
||||
AlgorithmRegex = "regex"
|
||||
AlgorithmFuzzy = "fuzzy"
|
||||
)
|
||||
|
||||
// fuzzyThreshold is the minimum normalized similarity ratio (0..1) for a
|
||||
// fuzzy match to count as a hit.
|
||||
const fuzzyThreshold = 0.85
|
||||
|
||||
// Match reports whether pattern matches somewhere in text, using the given
|
||||
// algorithm and case-sensitivity. Unknown algorithms and "none" always
|
||||
// return false (never auto-assigned). Malformed regex patterns return
|
||||
// false rather than panicking — callers are expected to log this
|
||||
// separately if desired.
|
||||
func Match(algorithm, pattern string, caseSensitive bool, text string) bool {
|
||||
if strings.TrimSpace(pattern) == "" {
|
||||
return false
|
||||
}
|
||||
if !caseSensitive {
|
||||
text = strings.ToLower(text)
|
||||
pattern = strings.ToLower(pattern)
|
||||
}
|
||||
|
||||
switch algorithm {
|
||||
case AlgorithmAny:
|
||||
return matchTerms(pattern, text, false)
|
||||
case AlgorithmAll:
|
||||
return matchTerms(pattern, text, true)
|
||||
case AlgorithmExact:
|
||||
return strings.Contains(text, pattern)
|
||||
case AlgorithmRegex:
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return re.MatchString(text)
|
||||
case AlgorithmFuzzy:
|
||||
return fuzzyContains(pattern, text)
|
||||
case AlgorithmNone:
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// FuzzyThreshold is the minimum FuzzyScore at which the fuzzy Match
|
||||
// algorithm counts as a hit. Exported so suggestion providers (see
|
||||
// internal/storage/metadata_suggestions.go) can pick their own lower floor
|
||||
// relative to the auto-assign threshold.
|
||||
const FuzzyThreshold = fuzzyThreshold
|
||||
|
||||
// FuzzyScore returns the best normalized similarity ratio (0..1) between
|
||||
// pattern and any whitespace-delimited window of text of pattern's own
|
||||
// word-count length, using the same normalized Levenshtein ratio and
|
||||
// word-window scan as the fuzzy Match algorithm. Unlike Match it returns the
|
||||
// raw score instead of a bool threshold decision, so callers can surface
|
||||
// near-miss candidates that scored below FuzzyThreshold. Returns 0 for an
|
||||
// empty pattern. This does NOT change the fuzzy Match algorithm — it only
|
||||
// exposes its underlying score.
|
||||
func FuzzyScore(pattern string, caseSensitive bool, text string) float64 {
|
||||
if strings.TrimSpace(pattern) == "" {
|
||||
return 0
|
||||
}
|
||||
if !caseSensitive {
|
||||
text = strings.ToLower(text)
|
||||
pattern = strings.ToLower(pattern)
|
||||
}
|
||||
patternWords := strings.Fields(pattern)
|
||||
if len(patternWords) == 0 {
|
||||
return 0
|
||||
}
|
||||
textWords := strings.Fields(text)
|
||||
n := len(patternWords)
|
||||
if len(textWords) < n {
|
||||
return levenshteinRatio(pattern, text)
|
||||
}
|
||||
best := 0.0
|
||||
for i := 0; i+n <= len(textWords); i++ {
|
||||
window := strings.Join(textWords[i:i+n], " ")
|
||||
if r := levenshteinRatio(pattern, window); r > best {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// tokenizePattern splits pattern into terms, honoring "quoted multi-word
|
||||
// terms" as single tokens (e.g. `invoice "Muster GmbH" urgent`).
|
||||
func tokenizePattern(pattern string) []string {
|
||||
var terms []string
|
||||
var cur strings.Builder
|
||||
inQuotes := false
|
||||
flush := func() {
|
||||
if t := strings.TrimSpace(cur.String()); t != "" {
|
||||
terms = append(terms, t)
|
||||
}
|
||||
cur.Reset()
|
||||
}
|
||||
for _, r := range pattern {
|
||||
switch {
|
||||
case r == '"':
|
||||
inQuotes = !inQuotes
|
||||
if !inQuotes {
|
||||
flush()
|
||||
}
|
||||
case r == ' ' && !inQuotes:
|
||||
flush()
|
||||
default:
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return terms
|
||||
}
|
||||
|
||||
// matchTerms implements the any/all algorithms: pattern is tokenized into
|
||||
// (possibly quoted, multi-word) terms; requireAll selects "all" vs "any".
|
||||
func matchTerms(pattern, text string, requireAll bool) bool {
|
||||
terms := tokenizePattern(pattern)
|
||||
if len(terms) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, term := range terms {
|
||||
hit := strings.Contains(text, term)
|
||||
if requireAll && !hit {
|
||||
return false
|
||||
}
|
||||
if !requireAll && hit {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return requireAll
|
||||
}
|
||||
|
||||
// fuzzyContains reports whether any whitespace-delimited window of text (of
|
||||
// pattern's own word-count length) is within fuzzyThreshold similarity of
|
||||
// pattern, using a normalized Levenshtein ratio. This is intentionally
|
||||
// simple (word-window scan, not a full substring-alignment fuzzy search) —
|
||||
// adequate for short tag/correspondent names against OCR text.
|
||||
func fuzzyContains(pattern, text string) bool {
|
||||
patternWords := strings.Fields(pattern)
|
||||
if len(patternWords) == 0 {
|
||||
return false
|
||||
}
|
||||
textWords := strings.Fields(text)
|
||||
n := len(patternWords)
|
||||
if len(textWords) < n {
|
||||
return levenshteinRatio(pattern, text) >= fuzzyThreshold
|
||||
}
|
||||
for i := 0; i+n <= len(textWords); i++ {
|
||||
window := strings.Join(textWords[i:i+n], " ")
|
||||
if levenshteinRatio(pattern, window) >= fuzzyThreshold {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// levenshteinRatio returns a normalized similarity ratio in [0,1]: 1 means
|
||||
// identical strings, 0 means completely dissimilar (edit distance equal to
|
||||
// the longer string's length).
|
||||
func levenshteinRatio(a, b string) float64 {
|
||||
maxLen := max(len([]rune(a)), len([]rune(b)))
|
||||
if maxLen == 0 {
|
||||
return 1
|
||||
}
|
||||
dist := levenshteinDistance(a, b)
|
||||
return 1 - float64(dist)/float64(maxLen)
|
||||
}
|
||||
|
||||
// levenshteinDistance computes the classic edit distance between two
|
||||
// strings (rune-aware), using a single-row dynamic-programming table to
|
||||
// keep memory usage O(min(len(a),len(b))).
|
||||
func levenshteinDistance(a, b string) int {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
if len(ra) < len(rb) {
|
||||
ra, rb = rb, ra
|
||||
}
|
||||
prev := make([]int, len(rb)+1)
|
||||
curr := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
del := prev[j] + 1
|
||||
ins := curr[j-1] + 1
|
||||
sub := prev[j-1] + cost
|
||||
curr[j] = min(del, min(ins, sub))
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package ocr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"mime"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// officeMimeTypes are the document formats we route through LibreOffice
|
||||
// (soffice --headless --convert-to pdf) before OCR. Mirrors the Paperless-ngx
|
||||
// Gotenberg / Docspell LibreOffice conversion stage: the resulting PDF is then
|
||||
// fed to the normal pdftotext / pdftoppm+tesseract pipeline (ocrPDF), so both
|
||||
// text-layer PDFs and scanned-image content inside the office file are covered.
|
||||
var officeMimeTypes = map[string]bool{
|
||||
// Word / text processing
|
||||
"application/msword": true,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
|
||||
"application/vnd.oasis.opendocument.text": true,
|
||||
"application/rtf": true,
|
||||
"text/rtf": true,
|
||||
// Spreadsheets
|
||||
"application/vnd.ms-excel": true,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
|
||||
"application/vnd.oasis.opendocument.spreadsheet": true,
|
||||
// Presentations
|
||||
"application/vnd.ms-powerpoint": true,
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": true,
|
||||
"application/vnd.oasis.opendocument.presentation": true,
|
||||
}
|
||||
|
||||
// isOfficeMime reports whether mimeType names an Office document format that
|
||||
// Extract routes through LibreOffice conversion.
|
||||
func isOfficeMime(mimeType string) bool {
|
||||
return officeMimeTypes[strings.ToLower(strings.TrimSpace(mimeType))]
|
||||
}
|
||||
|
||||
// officeConvertTimeout bounds a single LibreOffice conversion. Deliberately
|
||||
// more generous than the per-OCR-call timeout: a cold soffice start plus a
|
||||
// large spreadsheet can legitimately take longer than a tesseract page.
|
||||
func (e *Extractor) officeConvertTimeout() time.Duration {
|
||||
base := e.timeout()
|
||||
if base < 120*time.Second {
|
||||
return 120 * time.Second
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// officeToPDF converts an Office document at filePath to a temporary PDF via
|
||||
// LibreOffice headless, returning the PDF path and a cleanup func the caller
|
||||
// must defer. LibreOffice needs a private user-profile dir to run reliably and
|
||||
// concurrently (multiple soffice instances sharing the default profile clash),
|
||||
// so each conversion gets its own scratch dir under TmpDir.
|
||||
func (e *Extractor) officeToPDF(ctx context.Context, filePath string) (string, func(), error) {
|
||||
bin := e.sofficePath()
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
return "", nil, fmt.Errorf("ocr: libreoffice (%s) not found in PATH: %w", bin, err)
|
||||
}
|
||||
|
||||
workDir := filepath.Join(e.tmpDir(), "office-"+randomID())
|
||||
if err := os.MkdirAll(workDir, 0o700); err != nil {
|
||||
return "", nil, fmt.Errorf("ocr: create office convert dir: %w", err)
|
||||
}
|
||||
cleanup := func() { os.RemoveAll(workDir) }
|
||||
|
||||
profileDir := filepath.Join(workDir, "profile")
|
||||
|
||||
cctx, cancel := context.WithTimeout(ctx, e.officeConvertTimeout())
|
||||
defer cancel()
|
||||
|
||||
args := []string{
|
||||
"--headless", "--norestore", "--nologo", "--nolockcheck",
|
||||
"-env:UserInstallation=file://" + profileDir,
|
||||
"--convert-to", "pdf", "--outdir", workDir, filePath,
|
||||
}
|
||||
cmd := exec.CommandContext(cctx, bin, args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
cleanup()
|
||||
if cctx.Err() == context.DeadlineExceeded {
|
||||
return "", nil, fmt.Errorf("ocr: libreoffice conversion timed out after %s", e.officeConvertTimeout())
|
||||
}
|
||||
return "", nil, fmt.Errorf("ocr: libreoffice conversion failed: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
|
||||
// soffice names the output <input-basename>.pdf in --outdir. Prefer that
|
||||
// exact name, but fall back to the first *.pdf in the dir in case the base
|
||||
// name was sanitized.
|
||||
base := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
pdfPath := filepath.Join(workDir, base+".pdf")
|
||||
if _, err := os.Stat(pdfPath); err != nil {
|
||||
matches, _ := filepath.Glob(filepath.Join(workDir, "*.pdf"))
|
||||
if len(matches) == 0 {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("ocr: libreoffice produced no pdf for %s", filepath.Base(filePath))
|
||||
}
|
||||
pdfPath = matches[0]
|
||||
}
|
||||
e.log(slog.LevelInfo, "office document converted to pdf",
|
||||
"src", filepath.Base(filePath), "pdf", filepath.Base(pdfPath))
|
||||
return pdfPath, cleanup, nil
|
||||
}
|
||||
|
||||
// extractEML parses an .eml file and returns its human-readable text: a short
|
||||
// header block (Date/From/To/Cc/Subject, MIME-word-decoded) followed by the
|
||||
// concatenated text of every text/plain and (tag-stripped) text/html body part.
|
||||
// Binary attachments are ignored. Best-effort throughout — a malformed message
|
||||
// yields whatever could be parsed rather than an error, so an e-mail is never
|
||||
// silently dropped from full-text search.
|
||||
func (e *Extractor) extractEML(filePath string) (*Result, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ocr: read eml: %w", err)
|
||||
}
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ocr: parse eml: %w", err)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
dec := new(mime.WordDecoder)
|
||||
for _, h := range []string{"Date", "From", "To", "Cc", "Subject"} {
|
||||
v := msg.Header.Get(h)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if d, derr := dec.DecodeHeader(v); derr == nil {
|
||||
v = d
|
||||
}
|
||||
sb.WriteString(h)
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(v)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
body := mailPartText(msg.Header.Get("Content-Type"), msg.Header.Get("Content-Transfer-Encoding"), msg.Body)
|
||||
sb.WriteString(body)
|
||||
|
||||
return &Result{Text: strings.TrimSpace(sb.String())}, nil
|
||||
}
|
||||
|
||||
// mailPartText recursively extracts readable text from a MIME part. multipart/*
|
||||
// containers are walked; text/plain is decoded verbatim, text/html is decoded
|
||||
// and tag-stripped; everything else (attachments, images) is skipped.
|
||||
func mailPartText(contentType, cte string, body io.Reader) string {
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil || mediaType == "" {
|
||||
// No/invalid Content-Type: assume text/plain.
|
||||
return decodeMailBody(body, cte)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
boundary := params["boundary"]
|
||||
if boundary == "" {
|
||||
return ""
|
||||
}
|
||||
mr := multipart.NewReader(body, boundary)
|
||||
var parts []string
|
||||
for {
|
||||
p, perr := mr.NextPart()
|
||||
if perr != nil {
|
||||
break
|
||||
}
|
||||
pt := mailPartText(p.Header.Get("Content-Type"), p.Header.Get("Content-Transfer-Encoding"), p)
|
||||
p.Close()
|
||||
if strings.TrimSpace(pt) != "" {
|
||||
parts = append(parts, pt)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(mediaType, "text/plain"):
|
||||
return decodeMailBody(body, cte)
|
||||
case strings.HasPrefix(mediaType, "text/html"):
|
||||
return stripHTML(decodeMailBody(body, cte))
|
||||
default:
|
||||
return "" // attachment / binary part
|
||||
}
|
||||
}
|
||||
|
||||
// decodeMailBody reads a leaf MIME part, undoing base64 / quoted-printable
|
||||
// transfer encoding.
|
||||
func decodeMailBody(r io.Reader, cte string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(cte)) {
|
||||
case "base64":
|
||||
r = base64.NewDecoder(base64.StdEncoding, r)
|
||||
case "quoted-printable":
|
||||
r = quotedprintable.NewReader(r)
|
||||
}
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return string(b) // return whatever decoded before the error
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
var (
|
||||
htmlTagRe = regexp.MustCompile(`(?s)<(script|style)[^>]*>.*?</(script|style)>`)
|
||||
anyTagRe = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
wsRe = regexp.MustCompile(`[ \t\f\v]+`)
|
||||
blankRe = regexp.MustCompile(`\n{3,}`)
|
||||
)
|
||||
|
||||
// stripHTML reduces an HTML body to readable plain text: drops script/style
|
||||
// blocks and all tags, unescapes entities, and collapses runaway whitespace.
|
||||
func stripHTML(s string) string {
|
||||
s = htmlTagRe.ReplaceAllString(s, " ")
|
||||
s = anyTagRe.ReplaceAllString(s, " ")
|
||||
s = html.UnescapeString(s)
|
||||
s = wsRe.ReplaceAllString(s, " ")
|
||||
s = blankRe.ReplaceAllString(s, "\n\n")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
// Word-level bounding boxes for OCR text-highlight/overlay (Phase 1 —
|
||||
// datengrundlage only, see project memory
|
||||
// project_ocr_textmarkierung_overlay.md). This file adds:
|
||||
//
|
||||
// - WordBox / TSV extraction (tesseract's `tsv` output mode)
|
||||
// - a small geometry-transform mechanism to map word boxes from the
|
||||
// coordinate space of the final, fully-preprocessed image tesseract
|
||||
// actually recognized text on, back into the coordinate space of the
|
||||
// file the frontend actually displays to the user.
|
||||
//
|
||||
// Koordinatenraum (why this file exists at all): runTesseract's
|
||||
// preprocessing pipeline (clampImageSize -> deskewImage -> normalizeContrast
|
||||
// -> rotateForOSD, see ocr.go) can resize and rotate the image before
|
||||
// tesseract ever sees it. The frontend, however, always renders the
|
||||
// untouched original upload (internal/api/document_handlers.go
|
||||
// handleGetDocumentFile serves doc.StoragePath byte-for-byte; verified
|
||||
// 2026-07-30 — no transformed copy is ever persisted or served). Word boxes
|
||||
// from tesseract are therefore in the WRONG coordinate space for direct use
|
||||
// against the displayed image unless mapped back.
|
||||
//
|
||||
// Full forward order in runTesseract (each step optional):
|
||||
//
|
||||
// clampImageSize -> deskewImage | deskewImageHough -> normalizeContrast
|
||||
// -> rotateForOSD -> binarizeImage
|
||||
//
|
||||
// and, for image uploads only, one final forward step applied in ocrImage
|
||||
// AFTER the inversion above: the file's EXIF Orientation (see exif.go), which
|
||||
// moves the boxes from raw-pixel space into the space the browser actually
|
||||
// renders. Inversion happens strictly last-forward-step-first
|
||||
// (mapWordsToOriginal iterates the slice backwards), so any combination —
|
||||
// e.g. clamp + hough-deskew + OSD 90 degrees + EXIF 6 — composes correctly:
|
||||
// the geometric chain is undone in reverse, then EXIF is applied once on top.
|
||||
//
|
||||
// What is handled exactly vs. approximately:
|
||||
// - clampImageSize: pure uniform scale -> inverted exactly (simple ratio).
|
||||
// - normalizeContrast / binarizeImage: no geometry change; still measured
|
||||
// (geomChain.recordScale) rather than assumed, and dropped as identity.
|
||||
// - rotateForOSD: our own rotate90CW, always an exact multiple of 90
|
||||
// degrees -> inverted with pixel-exact integer math (mirrors the forward
|
||||
// loop in rotateImageFile step for step, no trig/rounding involved).
|
||||
// - deskewImage (ImageMagick `-deskew`, arbitrary small angle + canvas
|
||||
// resize to bound the rotated image): inverted via the standard
|
||||
// rotate-about-center formula using the angle ImageMagick reports via
|
||||
// `-print "%[deskew:angle]"` plus before/after pixel dimensions. This is
|
||||
// geometrically the correct construction for a generic "rotate and
|
||||
// expand canvas" operation. Sign convention reviewed 2026-07-30 against
|
||||
// ImageMagick's source behaviour: DeskewImage derives the `deskew:angle`
|
||||
// artifact from the same `degrees` it feeds into the affine matrix
|
||||
// [[cos,-sin],[sin,cos]], and AffineTransformImage expands the canvas
|
||||
// symmetrically about the centre (auto-crop off by default) — so the
|
||||
// centre-to-centre inverse with rad = -angleDeg below is the exact
|
||||
// transpose. Still not validated against a real deskewed sample's pixel
|
||||
// output, so treat it as reviewed-but-not-field-verified.
|
||||
// Per project memory (two prior deskew-angle tuning attempts were tested
|
||||
// against the doc id 4-9 corpus and rejected — see
|
||||
// project_deskew_border_trick_tested_negative.md and
|
||||
// project_deskew_disable_for_photos_tested_negative.md), do NOT blindly
|
||||
// adjust this formula's sign/rounding by trial and error; instead verify
|
||||
// against a real deskewed sample (overlay the mapped word boxes on the
|
||||
// original image) before touching it, and record the result either way.
|
||||
// - deskewImageHough: the angle is detected by hough_deskew.py but APPLIED
|
||||
// by `convert -rotate <angle>`, whose sign convention is documented and
|
||||
// unambiguous (positive = clockwise). The inverse below therefore IS
|
||||
// verified for this path — the unverified sign caveat above applies only
|
||||
// to ImageMagick's own `-deskew`/%[deskew:angle] pair.
|
||||
// - Steps whose geometry cannot be measured (image.DecodeConfig only knows
|
||||
// the formats this package imports, i.e. JPEG and PNG — a TIFF/BMP/WebP
|
||||
// upload fails every measurement while ImageMagick still processes it)
|
||||
// invalidate the whole chain via geomChain, and the document then gets NO
|
||||
// word boxes. Silently skipping such a step used to leave the remaining
|
||||
// transforms mapping into a coordinate space that no longer existed.
|
||||
//
|
||||
// PDF scope note: for the pdftoppm raster-fallback OCR path, WordBox
|
||||
// coordinates are mapped back to the *rasterized page PNG's* pixel space
|
||||
// (post-preprocessing -> pre-preprocessing raster), not further back into
|
||||
// PDF point/MediaBox coordinate space. The frontend currently renders PDFs
|
||||
// via the browser's native PDF viewer (iframe over the original file), which
|
||||
// uses PDF page-coordinate space, not raster pixels — mapping raster pixels
|
||||
// into that space is a straightforward additional scale step (raster DPI vs.
|
||||
// MediaBox size, both knowable via pdftoppm's -r 300 and `pdfinfo`) but is
|
||||
// left for whoever builds the overlay UI in a later phase, since it depends
|
||||
// on how that phase chooses to render PDF pages (canvas render at a chosen
|
||||
// DPI vs. native iframe).
|
||||
package ocr
|
||||
|
||||
import (
|
||||
"image"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
)
|
||||
|
||||
// WordBox is a single OCR-recognized word with its bounding box, already
|
||||
// mapped (best-effort — see package doc comment above) into the coordinate
|
||||
// space of the file the frontend actually displays for the document this
|
||||
// word was found in.
|
||||
type WordBox struct {
|
||||
Text string
|
||||
Left int
|
||||
Top int
|
||||
Width int
|
||||
Height int
|
||||
Confidence float64
|
||||
// Line, Block, Par come straight from tesseract's TSV line_num/block_num/
|
||||
// par_num columns, useful for later grouping words into lines/paragraphs
|
||||
// (e.g. for the eventual highlight-overlay UI) without re-deriving that
|
||||
// from raw positions.
|
||||
Line int
|
||||
Block int
|
||||
Par int
|
||||
// Page is the 1-based PDF page number this word was found on. Always 1
|
||||
// for image uploads (a single "page"; there is no page 0 in output).
|
||||
Page int
|
||||
}
|
||||
|
||||
// geomTransform describes one preprocessing step's effect on image geometry,
|
||||
// used to invert tesseract's word bounding boxes back towards the originally
|
||||
// displayed file. See the package doc comment for what is exact vs.
|
||||
// best-effort here.
|
||||
type geomTransform struct {
|
||||
oldW, oldH int
|
||||
newW, newH int
|
||||
// angleDeg is the clockwise rotation applied around the image center, in
|
||||
// degrees. Zero for a pure resize/no-op step.
|
||||
angleDeg float64
|
||||
// exact90 marks a rotation known to be an exact multiple of 90 degrees,
|
||||
// produced by our own rotate90CW (rotateForOSD) — inverted with
|
||||
// pixel-exact integer math rather than the trig formula used for
|
||||
// deskew's arbitrary angle.
|
||||
exact90 bool
|
||||
}
|
||||
|
||||
// invert maps a point (x, y) from the "new" (post-step) image's pixel space
|
||||
// back into the "old" (pre-step) image's pixel space.
|
||||
func (t geomTransform) invert(x, y float64) (float64, float64) {
|
||||
if t.angleDeg == 0 {
|
||||
if t.newW == 0 || t.newH == 0 {
|
||||
return x, y
|
||||
}
|
||||
scaleX := float64(t.oldW) / float64(t.newW)
|
||||
scaleY := float64(t.oldH) / float64(t.newH)
|
||||
return x * scaleX, y * scaleY
|
||||
}
|
||||
|
||||
if t.exact90 {
|
||||
steps := (int(math.Round(t.angleDeg)) / 90) % 4
|
||||
if steps < 0 {
|
||||
steps += 4
|
||||
}
|
||||
curW, curH := t.newW, t.newH
|
||||
cx, cy := x, y
|
||||
for i := 0; i < steps; i++ {
|
||||
// Forward step (rotateImageFile/rotate90CW) was, on pixel
|
||||
// INDICES: src(w,h) -> dst(h,w), src(x,y) -> dst(h-1-y, x).
|
||||
// mapWordsToOriginal feeds box EDGE coordinates (left..left+width,
|
||||
// i.e. a continuous [0,w] range, not indices [0,w-1]), so the
|
||||
// continuous form of the same rotation is used here:
|
||||
// dst(x,y) = (h - y, x) => src = (cy, curW - cx)
|
||||
// (Identical convention to applyEXIFOrientation in exif.go; using
|
||||
// the index form on edge coordinates would shift every box by one
|
||||
// pixel per rotation step.)
|
||||
nx := cy
|
||||
ny := float64(curW) - cx
|
||||
curW, curH = curH, curW
|
||||
cx, cy = nx, ny
|
||||
}
|
||||
return cx, cy
|
||||
}
|
||||
|
||||
// General case (ImageMagick -deskew): rotation about the image center
|
||||
// with the canvas expanded to bound the rotated image. Sign convention:
|
||||
// positive angleDeg == clockwise (ImageMagick `-rotate`), so the inverse
|
||||
// rotates by -angleDeg about the new centre and re-centres on the old
|
||||
// canvas. See package doc comment for how far this is verified per path
|
||||
// (hough: yes; ImageMagick's own -deskew: source-reviewed only).
|
||||
rad := -t.angleDeg * math.Pi / 180
|
||||
cxNew, cyNew := float64(t.newW)/2, float64(t.newH)/2
|
||||
cxOld, cyOld := float64(t.oldW)/2, float64(t.oldH)/2
|
||||
dx, dy := x-cxNew, y-cyNew
|
||||
cos, sin := math.Cos(rad), math.Sin(rad)
|
||||
rx := dx*cos - dy*sin
|
||||
ry := dx*sin + dy*cos
|
||||
return rx + cxOld, ry + cyOld
|
||||
}
|
||||
|
||||
// isIdentity reports whether this step changed no geometry at all (same
|
||||
// dimensions, no rotation) and can therefore be dropped from the chain.
|
||||
func (t geomTransform) isIdentity() bool {
|
||||
return t.angleDeg == 0 && t.oldW == t.newW && t.oldH == t.newH
|
||||
}
|
||||
|
||||
// mapWordsToOriginal applies transforms in reverse (last-applied-preprocessing-
|
||||
// step-first) order, mutating words in place to convert their bounding boxes
|
||||
// from final-tesseract-image space into the coordinate space of the file
|
||||
// before any of these transforms ran.
|
||||
//
|
||||
// ALL FOUR corners are inverted, not just top-left/bottom-right. That matters
|
||||
// as soon as a non-90-degree rotation (deskew) is in the chain: under a
|
||||
// rotation the two opposite corners alone no longer span the rotated
|
||||
// rectangle's axis-aligned bounding box — for a typical 2-3 degree deskew the
|
||||
// resulting box is systematically too narrow/short and offset, and at angles
|
||||
// approaching 45 degrees it collapses towards zero size. The result here is
|
||||
// the true axis-aligned bounding box of the back-rotated word quad, which is
|
||||
// what the frontend overlay draws.
|
||||
func mapWordsToOriginal(words []WordBox, transforms []geomTransform) {
|
||||
if len(transforms) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range words {
|
||||
l, t := float64(words[i].Left), float64(words[i].Top)
|
||||
r, b := float64(words[i].Left+words[i].Width), float64(words[i].Top+words[i].Height)
|
||||
corners := [4][2]float64{{l, t}, {r, t}, {r, b}, {l, b}}
|
||||
for c := range corners {
|
||||
x, y := corners[c][0], corners[c][1]
|
||||
for j := len(transforms) - 1; j >= 0; j-- {
|
||||
x, y = transforms[j].invert(x, y)
|
||||
}
|
||||
corners[c][0], corners[c][1] = x, y
|
||||
}
|
||||
minX, maxX := corners[0][0], corners[0][0]
|
||||
minY, maxY := corners[0][1], corners[0][1]
|
||||
for c := 1; c < 4; c++ {
|
||||
minX = math.Min(minX, corners[c][0])
|
||||
maxX = math.Max(maxX, corners[c][0])
|
||||
minY = math.Min(minY, corners[c][1])
|
||||
maxY = math.Max(maxY, corners[c][1])
|
||||
}
|
||||
words[i].Left = int(math.Round(minX))
|
||||
words[i].Top = int(math.Round(minY))
|
||||
words[i].Width = int(math.Round(maxX - minX))
|
||||
words[i].Height = int(math.Round(maxY - minY))
|
||||
}
|
||||
}
|
||||
|
||||
// geomChain collects the geometry-changing preprocessing steps of a single
|
||||
// runTesseract pass, so word boxes can be inverted back into the source
|
||||
// image's coordinate space afterwards.
|
||||
//
|
||||
// The important property it enforces (this was a real, silent bug before):
|
||||
// a preprocessing step that DID change geometry but whose geometry could not
|
||||
// be measured must invalidate the whole chain, not just be skipped. Skipping
|
||||
// it leaves the remaining transforms mapping into a coordinate space that no
|
||||
// longer exists, and the frontend then draws a confidently wrong overlay.
|
||||
// The realistic trigger is an upload format image.DecodeConfig cannot read:
|
||||
// this package only registers image/jpeg and image/png, so TIFF/BMP/WebP/GIF
|
||||
// uploads (all accepted as image/*) fail every decodeImageDims call while
|
||||
// ImageMagick happily processes them. Rather than misplace boxes we return
|
||||
// none for those documents.
|
||||
type geomChain struct {
|
||||
steps []geomTransform
|
||||
broken bool
|
||||
log func(level slog.Level, msg string, args ...any)
|
||||
}
|
||||
|
||||
// recordScale books a step that may only scale the image uniformly
|
||||
// (clampImageSize) or must not change geometry at all (normalizeContrast,
|
||||
// binarizeImage). Identity steps are dropped.
|
||||
func (c *geomChain) recordScale(step, oldPath, newPath string) {
|
||||
t, ok := buildScaleTransform(oldPath, newPath)
|
||||
if !ok {
|
||||
c.fail(step, "image dimensions unreadable (unsupported format for image.DecodeConfig?)")
|
||||
return
|
||||
}
|
||||
if t.isIdentity() {
|
||||
return
|
||||
}
|
||||
c.steps = append(c.steps, t)
|
||||
}
|
||||
|
||||
// recordRotation books a rotation step (deskewImage/deskewImageHough/
|
||||
// rotateForOSD). A reported angle of 0 combined with changed dimensions means
|
||||
// the angle was lost (e.g. an ImageMagick build not populating
|
||||
// %[deskew:angle]) while a rotation really was applied — unrecoverable, so
|
||||
// the chain is invalidated instead of silently mapping with angle 0.
|
||||
func (c *geomChain) recordRotation(step, oldPath, newPath string, angleDeg float64, exact90 bool) {
|
||||
t, ok := buildRotationTransform(oldPath, newPath, angleDeg, exact90)
|
||||
if !ok {
|
||||
c.fail(step, "image dimensions unreadable (unsupported format for image.DecodeConfig?)")
|
||||
return
|
||||
}
|
||||
if angleDeg == 0 && (t.oldW != t.newW || t.oldH != t.newH) {
|
||||
c.fail(step, "rotation applied but angle unknown (0) — cannot invert")
|
||||
return
|
||||
}
|
||||
if t.isIdentity() {
|
||||
return
|
||||
}
|
||||
c.steps = append(c.steps, t)
|
||||
}
|
||||
|
||||
func (c *geomChain) fail(step, reason string) {
|
||||
c.broken = true
|
||||
if c.log != nil {
|
||||
c.log(slog.LevelWarn, "ocr word boxes disabled: preprocessing geometry not invertible",
|
||||
"step", step, "reason", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// transforms returns the collected chain; ok is false when any step could not
|
||||
// be recorded reliably, in which case callers must not emit word boxes at all.
|
||||
func (c *geomChain) transforms() ([]geomTransform, bool) {
|
||||
if c.broken {
|
||||
return nil, false
|
||||
}
|
||||
return c.steps, true
|
||||
}
|
||||
|
||||
// decodeImageDims returns the pixel width/height of the image at path
|
||||
// without decoding full pixel data (image.DecodeConfig only reads the
|
||||
// header).
|
||||
func decodeImageDims(path string) (w, h int, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
cfg, _, err := image.DecodeConfig(f)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return cfg.Width, cfg.Height, nil
|
||||
}
|
||||
|
||||
// buildScaleTransform records a pure-resize geometry step (clampImageSize)
|
||||
// by decoding both images' dimensions. ok is false if either image's
|
||||
// dimensions cannot be read, in which case the caller should skip recording
|
||||
// a transform (best-effort, same tolerance as the rest of this package).
|
||||
func buildScaleTransform(oldPath, newPath string) (geomTransform, bool) {
|
||||
oldW, oldH, err := decodeImageDims(oldPath)
|
||||
if err != nil {
|
||||
return geomTransform{}, false
|
||||
}
|
||||
newW, newH, err := decodeImageDims(newPath)
|
||||
if err != nil {
|
||||
return geomTransform{}, false
|
||||
}
|
||||
return geomTransform{oldW: oldW, oldH: oldH, newW: newW, newH: newH}, true
|
||||
}
|
||||
|
||||
// buildRotationTransform records a rotation geometry step (deskewImage or
|
||||
// rotateForOSD) by decoding both images' dimensions plus the rotation angle
|
||||
// applied. exact90 distinguishes rotateForOSD's pixel-exact 90-degree
|
||||
// rotations from deskewImage's arbitrary-angle, best-effort inverse.
|
||||
func buildRotationTransform(oldPath, newPath string, angleDeg float64, exact90 bool) (geomTransform, bool) {
|
||||
oldW, oldH, err := decodeImageDims(oldPath)
|
||||
if err != nil {
|
||||
return geomTransform{}, false
|
||||
}
|
||||
newW, newH, err := decodeImageDims(newPath)
|
||||
if err != nil {
|
||||
return geomTransform{}, false
|
||||
}
|
||||
return geomTransform{
|
||||
oldW: oldW, oldH: oldH,
|
||||
newW: newW, newH: newH,
|
||||
angleDeg: angleDeg,
|
||||
exact90: exact90,
|
||||
}, true
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package ocr
|
||||
|
||||
// EXIF-Orientierung für den OCR-Wortbox-Koordinatenraum.
|
||||
//
|
||||
// Warum diese Datei existiert (Root Cause des Overlay-Versatzes, 2026-07-30):
|
||||
// Die Vorverarbeitungskette in runTesseract arbeitet ausschließlich auf ROHEN
|
||||
// Pixeln — weder tesseract noch ImageMagick `convert` wenden das EXIF-Tag
|
||||
// `Orientation` von selbst an (dafür bräuchte es explizit `-auto-orient`).
|
||||
// mapWordsToOriginal rechnet die Wortboxen folglich in den ROH-Pixelraum der
|
||||
// gespeicherten Datei zurück.
|
||||
//
|
||||
// Der Browser tut aber genau das Gegenteil: seit der Vereinheitlichung von
|
||||
// `image-orientation: from-image` als Default (Chrome 81+, Firefox 26+,
|
||||
// Safari 13.1+) rendert er ein <img> IMMER EXIF-orientiert und meldet auch
|
||||
// naturalWidth/naturalHeight bereits gedreht. Bei einem Handyfoto mit
|
||||
// Orientation 6/8 (Hochkant aufgenommen, Sensor liefert Querformat-Pixel)
|
||||
// zeigt das Frontend also ein 3000x4000-Bild, während jede Wortbox in
|
||||
// 4000x3000-Rohkoordinaten vorliegt: das Overlay ist um 90 Grad verdreht und
|
||||
// liegt zum Teil komplett außerhalb des Bildes. Genau das ist das gemeldete
|
||||
// "passt nicht mit den OCR-Feldern" — kein Subpixel-/Deskew-Problem, sondern
|
||||
// ein kompletter Raumwechsel.
|
||||
//
|
||||
// Lösung: nach der Rücktransformation in den Rohraum wird hier EINMAL die
|
||||
// EXIF-Orientierung vorwärts angewandt, damit die gespeicherten Koordinaten im
|
||||
// tatsächlich DARGESTELLTEN Raum liegen (das ist auch die dokumentierte
|
||||
// Semantik der ocr_words-Spalten und der API — "Koordinatenraum der
|
||||
// angezeigten Datei"). Orientation 1 (bzw. kein EXIF, PNG, PDF-Raster) ist ein
|
||||
// No-Op, betrifft also nur genau die Fotos, bei denen der Browser dreht.
|
||||
//
|
||||
// Der EXIF-Parser ist bewusst minimal und dependency-frei (nur stdlib): er
|
||||
// sucht den APP1/"Exif\0\0"-Marker, liest den TIFF-Header und die IFD0-Einträge
|
||||
// und gibt Tag 0x0112 zurück. Alles andere (XMP, MakerNotes, Thumbnails) wird
|
||||
// nicht angefasst.
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
)
|
||||
|
||||
// errNoEXIFOrientation signalisiert "kein verwertbares Orientation-Tag" —
|
||||
// Aufrufer behandeln das wie Orientation 1.
|
||||
var errNoEXIFOrientation = errors.New("ocr: no exif orientation")
|
||||
|
||||
// maxEXIFScan begrenzt, wie weit wir im JPEG nach dem APP1-Segment suchen.
|
||||
// EXIF steht per Spezifikation direkt hinter SOI; die Grenze verhindert nur,
|
||||
// dass eine kaputte Datei uns durch das ganze Bild laufen lässt.
|
||||
const maxEXIFScan = 1 << 20 // 1 MiB
|
||||
|
||||
// jpegEXIFOrientation liefert den Wert des EXIF-Tags Orientation (1..8) der
|
||||
// Datei an path. Für Nicht-JPEGs, JPEGs ohne EXIF, unlesbare oder unplausible
|
||||
// Werte wird 1 (= keine Drehung) zurückgegeben; ein Fehler wird nur zur
|
||||
// optionalen Diagnose mitgegeben und ist für Aufrufer nicht fatal.
|
||||
func jpegEXIFOrientation(path string) (int, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var soi [2]byte
|
||||
if _, err := io.ReadFull(f, soi[:]); err != nil {
|
||||
return 1, err
|
||||
}
|
||||
if soi[0] != 0xFF || soi[1] != 0xD8 { // kein JPEG (PNG/TIFF/…): kein EXIF-Handling
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
|
||||
scanned := 0
|
||||
var hdr [4]byte
|
||||
for scanned < maxEXIFScan {
|
||||
// Marker suchen: beliebig viele 0xFF-Füllbytes, dann der Markercode.
|
||||
var b [1]byte
|
||||
if _, err := io.ReadFull(f, b[:]); err != nil {
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
scanned++
|
||||
if b[0] != 0xFF {
|
||||
continue
|
||||
}
|
||||
for {
|
||||
if _, err := io.ReadFull(f, b[:]); err != nil {
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
scanned++
|
||||
if b[0] != 0xFF {
|
||||
break
|
||||
}
|
||||
}
|
||||
marker := b[0]
|
||||
switch {
|
||||
case marker == 0xDA || marker == 0xD9:
|
||||
// SOS (Bilddaten) bzw. EOI (Dateiende) erreicht: ab hier kann kein
|
||||
// APP1/EXIF-Segment mehr kommen. Muss VOR der Prüfung auf
|
||||
// längenlose Marker stehen — 0xD9 fällt sonst in den
|
||||
// RST/D0..D7-Bereich und wir würden durch die Bilddaten weiterlaufen.
|
||||
return 1, errNoEXIFOrientation
|
||||
case marker == 0x00 || marker == 0xFF:
|
||||
continue // Byte-Stuffing/Füllbyte, kein echter Marker
|
||||
case marker == 0xD8 || marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7):
|
||||
continue // SOI/TEM/RSTn: längenlose Marker
|
||||
}
|
||||
if _, err := io.ReadFull(f, hdr[:2]); err != nil {
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
segLen := int(binary.BigEndian.Uint16(hdr[:2]))
|
||||
if segLen < 2 {
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
payload := make([]byte, segLen-2)
|
||||
if _, err := io.ReadFull(f, payload); err != nil {
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
scanned += segLen
|
||||
if marker != 0xE1 || len(payload) < 6 || string(payload[:6]) != "Exif\x00\x00" {
|
||||
continue
|
||||
}
|
||||
return orientationFromTIFF(payload[6:])
|
||||
}
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
|
||||
// orientationFromTIFF liest Tag 0x0112 aus dem IFD0 eines TIFF-Headers (der
|
||||
// Nutzlast eines EXIF-APP1-Segments ohne "Exif\0\0"-Präfix).
|
||||
func orientationFromTIFF(tiff []byte) (int, error) {
|
||||
if len(tiff) < 8 {
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
var bo binary.ByteOrder
|
||||
switch {
|
||||
case tiff[0] == 'I' && tiff[1] == 'I':
|
||||
bo = binary.LittleEndian
|
||||
case tiff[0] == 'M' && tiff[1] == 'M':
|
||||
bo = binary.BigEndian
|
||||
default:
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
if bo.Uint16(tiff[2:4]) != 42 {
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
offset := int(bo.Uint32(tiff[4:8]))
|
||||
if offset < 8 || offset+2 > len(tiff) {
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
count := int(bo.Uint16(tiff[offset : offset+2]))
|
||||
entry := offset + 2
|
||||
for i := 0; i < count; i++ {
|
||||
if entry+12 > len(tiff) {
|
||||
break
|
||||
}
|
||||
tag := bo.Uint16(tiff[entry : entry+2])
|
||||
typ := bo.Uint16(tiff[entry+2 : entry+4])
|
||||
if tag == 0x0112 && typ == 3 /* SHORT */ {
|
||||
v := int(bo.Uint16(tiff[entry+8 : entry+10]))
|
||||
if v >= 1 && v <= 8 {
|
||||
return v, nil
|
||||
}
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
entry += 12
|
||||
}
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
|
||||
// applyEXIFOrientation überführt Wortboxen aus dem ROH-Pixelraum eines Bildes
|
||||
// (Breite rawW, Höhe rawH) in den vom Browser DARGESTELLTEN Raum, indem die
|
||||
// EXIF-Orientierung orientation (1..8) vorwärts angewandt wird. orientation 1
|
||||
// sowie ungültige Werte/Dimensionen sind ein No-Op.
|
||||
//
|
||||
// Die acht EXIF-Fälle entsprechen den üblichen Definitionen (2/4/5/7 enthalten
|
||||
// eine Spiegelung; sie kommen bei Kameras praktisch nicht vor, werden aber der
|
||||
// Vollständigkeit halber korrekt behandelt, damit hier nie stillschweigend ein
|
||||
// falscher Raum entsteht):
|
||||
//
|
||||
// 1 (x, y) 2 (W-x, y) 3 (W-x, H-y) 4 (x, H-y)
|
||||
// 5 (y, x) 6 (H-y, x) 7 (H-y, W-x) 8 (y, W-x)
|
||||
//
|
||||
// Bei 5..8 tauschen Breite und Höhe die Rollen — genau der Fall, in dem das
|
||||
// Overlay ohne diese Korrektur komplett neben dem Bild landet.
|
||||
func applyEXIFOrientation(words []WordBox, orientation, rawW, rawH int) {
|
||||
if orientation <= 1 || orientation > 8 || rawW <= 0 || rawH <= 0 {
|
||||
return
|
||||
}
|
||||
w, h := float64(rawW), float64(rawH)
|
||||
mapPoint := func(x, y float64) (float64, float64) {
|
||||
switch orientation {
|
||||
case 2:
|
||||
return w - x, y
|
||||
case 3:
|
||||
return w - x, h - y
|
||||
case 4:
|
||||
return x, h - y
|
||||
case 5:
|
||||
return y, x
|
||||
case 6:
|
||||
return h - y, x
|
||||
case 7:
|
||||
return h - y, w - x
|
||||
case 8:
|
||||
return y, w - x
|
||||
default:
|
||||
return x, y
|
||||
}
|
||||
}
|
||||
for i := range words {
|
||||
x0, y0 := mapPoint(float64(words[i].Left), float64(words[i].Top))
|
||||
x1, y1 := mapPoint(float64(words[i].Left+words[i].Width), float64(words[i].Top+words[i].Height))
|
||||
if x0 > x1 {
|
||||
x0, x1 = x1, x0
|
||||
}
|
||||
if y0 > y1 {
|
||||
y0, y1 = y1, y0
|
||||
}
|
||||
// math.Round statt int(v+0.5): Wortboxen können nach der
|
||||
// Rücktransformation aus einem Deskew-Schritt knapp negative
|
||||
// Randkoordinaten haben, dort rundet int(v+0.5) in die falsche Richtung.
|
||||
words[i].Left = int(math.Round(x0))
|
||||
words[i].Top = int(math.Round(y0))
|
||||
words[i].Width = int(math.Round(x1 - x0))
|
||||
words[i].Height = int(math.Round(y1 - y0))
|
||||
}
|
||||
}
|
||||
+1060
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""hough_deskew.py — fine-skew angle detector for the archivdms OCR pipeline.
|
||||
|
||||
Sidecar script for internal/ocr/ocr.go's `hough` deskew method
|
||||
(config.OCRConfig.DeskewMethod == "hough"), an ALTERNATIVE to the default
|
||||
ImageMagick `-deskew` peak/valley text-line projection analysis
|
||||
(deskewImage() in ocr.go). ImageMagick's approach needs surrounding
|
||||
background/margin to find the page's background rows/columns and fails on
|
||||
tightly-cropped phone photos of receipts (no margin context) — see
|
||||
project_deskew_disable_for_photos_tested_negative and
|
||||
project_deskew_border_trick_tested_negative in agent memory for two
|
||||
previously-tried and rejected workarounds. This script separates ANGLE
|
||||
DETECTION (via OpenCV, this file) from angle APPLICATION (plain `convert
|
||||
-rotate <deg>` in ocr.go) per the recommendation that produced this rewrite.
|
||||
|
||||
Usage:
|
||||
python3 hough_deskew.py <image-path>
|
||||
|
||||
Behavior:
|
||||
- Reads the image with OpenCV, grayscale + Otsu threshold.
|
||||
- Finds the largest contour by area and takes cv2.minAreaRect() of it.
|
||||
This is deliberately NOT text-line-projection-based (that is exactly
|
||||
what ImageMagick already does and what fails on cropped photos) —
|
||||
minAreaRect degrades gracefully to "the boundary of whatever content is
|
||||
in frame" even when that content fills the whole image, which is
|
||||
normally the case for a tightly-cropped phone photo.
|
||||
- Falls back to cv2.HoughLinesP() long-line-angle voting if no usable
|
||||
contour is found (e.g. near-blank background, no single dominant
|
||||
shape) — takes the median angle of detected line segments within
|
||||
+/-45 degrees of horizontal.
|
||||
- Prints exactly one float (the skew angle in degrees, ImageMagick
|
||||
`-rotate` sign convention: positive = clockwise) to stdout and exits 0
|
||||
on success.
|
||||
- On any failure (bad path, unreadable image, no contours/lines found),
|
||||
prints nothing to stdout, writes a one-line reason to stderr, and
|
||||
exits non-zero. ocr.go's houghDeskewAngle treats this as "angle 0,
|
||||
keep going" — never a fatal OCR error.
|
||||
|
||||
Dependencies: opencv-python (or the Debian python3-opencv apt package, which
|
||||
pulls in numpy as a transitive dependency) — no other third-party packages.
|
||||
Deliberately not using the `deskew` PyPI package: it wraps a very similar
|
||||
Radon/Hough approach but pulls in scikit-image, a much heavier dependency
|
||||
tree, for no accuracy benefit found in testing.
|
||||
"""
|
||||
import sys
|
||||
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
except ImportError as exc: # pragma: no cover - environment/dependency issue
|
||||
print(f"hough_deskew: missing dependency: {exc}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def _angle_from_min_area_rect(gray: "np.ndarray"):
|
||||
"""Return a skew angle in degrees via Otsu threshold + largest contour's
|
||||
minAreaRect, or None if no usable contour was found."""
|
||||
_, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
|
||||
contours, _ = cv2.findContours(thresh, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
|
||||
if not contours:
|
||||
return None
|
||||
|
||||
largest = max(contours, key=cv2.contourArea)
|
||||
# Ignore contours covering too little of the frame — noise/artifacts, not
|
||||
# the document itself.
|
||||
img_area = gray.shape[0] * gray.shape[1]
|
||||
if cv2.contourArea(largest) < 0.05 * img_area:
|
||||
return None
|
||||
|
||||
rect = cv2.minAreaRect(largest)
|
||||
angle = rect[2] # OpenCV: angle in (-90, 0] for cv2.minAreaRect
|
||||
|
||||
# Normalize to the smallest rotation that would make the rect's long side
|
||||
# horizontal (matches ImageMagick -deskew / -rotate's small-angle
|
||||
# convention rather than cv2's raw (-90, 0] range).
|
||||
w, h = rect[1]
|
||||
if w < h:
|
||||
angle = angle + 90
|
||||
if angle > 45:
|
||||
angle -= 90
|
||||
elif angle < -45:
|
||||
angle += 90
|
||||
return angle
|
||||
|
||||
|
||||
def _angle_from_hough_lines(gray: "np.ndarray"):
|
||||
"""Fallback: median angle of long line segments detected via
|
||||
HoughLinesP, restricted to +/-45 degrees of horizontal. Returns None if
|
||||
no usable lines were found."""
|
||||
edges = cv2.Canny(gray, 50, 150, apertureSize=3)
|
||||
lines = cv2.HoughLinesP(
|
||||
edges, 1, np.pi / 180, threshold=100, minLineLength=gray.shape[1] // 4, maxLineGap=20
|
||||
)
|
||||
if lines is None or len(lines) == 0:
|
||||
return None
|
||||
|
||||
angles = []
|
||||
for line in lines:
|
||||
x1, y1, x2, y2 = line[0]
|
||||
dx, dy = x2 - x1, y2 - y1
|
||||
if dx == 0:
|
||||
continue
|
||||
angle = np.degrees(np.arctan2(dy, dx))
|
||||
if -45 <= angle <= 45:
|
||||
angles.append(angle)
|
||||
if not angles:
|
||||
return None
|
||||
return float(np.median(angles))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
print("hough_deskew: usage: hough_deskew.py <image-path>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
path = sys.argv[1]
|
||||
img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
|
||||
if img is None:
|
||||
print(f"hough_deskew: could not read image: {path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
angle = _angle_from_min_area_rect(img)
|
||||
if angle is None:
|
||||
angle = _angle_from_hough_lines(img)
|
||||
if angle is None:
|
||||
print("hough_deskew: no usable contour or line angle found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"{angle:.4f}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,514 @@
|
||||
// Package pagesplit implements barcode separator-page splitting for
|
||||
// multi-page PDF ingest ("Trennseiten-Split", inspired by Paperless-ngx's
|
||||
// ASN/separator barcode feature, adapted to archivdms's ingest pipeline).
|
||||
//
|
||||
// Idea: a scanner operator interleaves printed separator sheets carrying a
|
||||
// well-known barcode (default value "ARCHIVDMS-SPLIT") between the individual
|
||||
// receipts of one long scan run. At ingest the PDF is checked page by page for
|
||||
// that barcode; where it is found, the document is cut, and the separator page
|
||||
// itself is dropped (it is a control sheet, not content — same behaviour as
|
||||
// Paperless-ngx). Each resulting part then runs through the completely normal
|
||||
// staging path (own WORM file, own hash/duplicate check, own processing job).
|
||||
//
|
||||
// Design constraints this package follows, all inherited from the existing
|
||||
// codebase:
|
||||
//
|
||||
// - No CGO, no PDF library: everything is done by shelling out to the
|
||||
// poppler-utils binaries that are already a service dependency of the OCR
|
||||
// pipeline (pdfinfo, pdftoppm, pdfseparate, pdfunite) plus zbarimg via
|
||||
// internal/barcode. Deliberately NOT qpdf/pdftk — those would be a new
|
||||
// package dependency for something poppler already covers.
|
||||
// - Best-effort, fail-safe: every error path returns "no split" rather than
|
||||
// failing the upload. A scanner run that cannot be analysed must still be
|
||||
// archived, unsplit, rather than rejected. The one thing that is never
|
||||
// silently swallowed is a *partially* produced split — Split either yields
|
||||
// a complete set of parts or nothing at all.
|
||||
// - Off by default (Detector.Enabled), per the project's conservative rule
|
||||
// for new preprocessing behaviour (cf. the Otsu binarize switch in
|
||||
// internal/ocr).
|
||||
//
|
||||
// Scope note: only application/pdf is handled. Multi-page TIFF is a
|
||||
// theoretically possible scanner output but is not currently produced by any
|
||||
// archivdms ingest path (HTTP upload and the SFTP watcher both hand single
|
||||
// images or PDFs to the pipeline), so it is intentionally out of scope here
|
||||
// rather than half-supported.
|
||||
package pagesplit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"archivdms/internal/barcode"
|
||||
)
|
||||
|
||||
// DefaultMarker is the barcode payload that marks a separator page when no
|
||||
// other value is configured. Chosen to be unambiguous and unlikely to collide
|
||||
// with a taxonomy barcode (internal/storage/taxonomy.go barcode_value) or with
|
||||
// anything printed on a real invoice.
|
||||
const DefaultMarker = "ARCHIVDMS-SPLIT"
|
||||
|
||||
// defaultRasterDPI is the resolution separator detection rasterizes at. Much
|
||||
// lower than the 300 dpi the OCR pipeline uses: a separator sheet carries one
|
||||
// large, high-contrast barcode, and 150 dpi decodes those reliably while
|
||||
// keeping the extra pdftoppm pass cheap on long scan runs.
|
||||
const defaultRasterDPI = 150
|
||||
|
||||
// defaultTimeout bounds each individual poppler subprocess call.
|
||||
const defaultTimeout = 120 * time.Second
|
||||
|
||||
// defaultMaxPages caps how many pages are analysed. A scan run beyond this is
|
||||
// treated as "not analysable" (no split, archived as one document) instead of
|
||||
// spending unbounded time rasterizing — the same bounded-worst-case reasoning
|
||||
// as internal/ocr's maxImagePixels clamp.
|
||||
const defaultMaxPages = 200
|
||||
|
||||
// Detector performs separator-page detection and PDF splitting.
|
||||
//
|
||||
// Construct via New and set the optional fields afterwards; the zero value is
|
||||
// disabled and therefore a safe no-op.
|
||||
type Detector struct {
|
||||
// Enabled turns the whole feature on. False (zero value) => Split always
|
||||
// reports "no split".
|
||||
Enabled bool
|
||||
// Marker is the barcode payload identifying a separator page. Empty
|
||||
// defaults to DefaultMarker. Compared case-insensitively after trimming.
|
||||
Marker string
|
||||
// MarkerPrefix switches the comparison from "equals Marker" to "starts
|
||||
// with Marker", so operators can encode extra data on the separator sheet
|
||||
// (e.g. "ARCHIVDMS-SPLIT-2026-INVOICES") with a single configured value.
|
||||
MarkerPrefix bool
|
||||
// PdftoppmPath/PdfinfoPath/PdfseparatePath/PdfunitePath name the poppler
|
||||
// binaries. Empty values fall back to the plain command names.
|
||||
PdftoppmPath string
|
||||
PdfinfoPath string
|
||||
PdfseparatePath string
|
||||
PdfunitePath string
|
||||
// TmpDir is the scratch base directory (config.StorageConfig.OCRTmpPath()).
|
||||
// Every Split call gets its own subdirectory, removed by Result.Cleanup.
|
||||
TmpDir string
|
||||
// RasterDPI overrides defaultRasterDPI. MaxPages overrides defaultMaxPages.
|
||||
RasterDPI int
|
||||
MaxPages int
|
||||
// Timeout bounds each subprocess call. Zero => defaultTimeout.
|
||||
Timeout time.Duration
|
||||
// Logger receives best-effort diagnostics. Optional (nil = silent).
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// New builds a Detector from the resolved config values.
|
||||
func New(enabled bool, marker string, markerPrefix bool, pdftoppmPath, tmpDir string) *Detector {
|
||||
return &Detector{
|
||||
Enabled: enabled,
|
||||
Marker: marker,
|
||||
MarkerPrefix: markerPrefix,
|
||||
PdftoppmPath: pdftoppmPath,
|
||||
TmpDir: tmpDir,
|
||||
}
|
||||
}
|
||||
|
||||
// Result describes a completed split.
|
||||
type Result struct {
|
||||
// Parts holds the absolute paths of the produced part PDFs, in original
|
||||
// page order. Always at least one entry when Split reports split == true.
|
||||
Parts []string
|
||||
// PartPageRanges[i] holds the 1-based [first,last] page numbers of Parts[i]
|
||||
// within the original document — audit-log material, so the aggregation of
|
||||
// pages into parts stays reconstructible after the original is gone.
|
||||
PartPageRanges [][2]int
|
||||
// SeparatorPages holds the 1-based page numbers that carried the marker
|
||||
// barcode and were therefore dropped.
|
||||
SeparatorPages []int
|
||||
// PageCount is the original document's total page count.
|
||||
PageCount int
|
||||
// Cleanup removes the scratch directory holding Parts. Never nil when
|
||||
// Split returned split == true; callers must defer it.
|
||||
Cleanup func()
|
||||
}
|
||||
|
||||
// Split analyses pdfPath for separator pages and, if any are found, produces
|
||||
// one part PDF per content segment.
|
||||
//
|
||||
// Returns split == false (with a nil Result) for every "carry on normally"
|
||||
// outcome: detector disabled, poppler/zbarimg missing, fewer than two pages,
|
||||
// page count above MaxPages, no separator barcode found, or every page being a
|
||||
// separator page. Only genuinely unexpected failures return an error, and even
|
||||
// those are meant to be treated by the caller as "archive unsplit" plus an
|
||||
// audit entry — never as an upload failure.
|
||||
//
|
||||
// pdfPath must be a scratch/inbox file: it is only ever read, but the whole
|
||||
// point of this function is that it runs BEFORE the file becomes a WORM
|
||||
// archive object, so it must never be pointed at store/.
|
||||
func (d *Detector) Split(ctx context.Context, pdfPath string) (res *Result, split bool, err error) {
|
||||
if d == nil || !d.Enabled {
|
||||
return nil, false, nil
|
||||
}
|
||||
for _, bin := range []string{d.pdfinfoPath(), d.pdftoppmPath(), d.pdfseparatePath(), d.pdfunitePath()} {
|
||||
if _, lookErr := exec.LookPath(bin); lookErr != nil {
|
||||
d.log(slog.LevelWarn, "pagesplit skipped: poppler binary not found in PATH",
|
||||
"binary", bin, "err", lookErr)
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
if _, lookErr := exec.LookPath("zbarimg"); lookErr != nil {
|
||||
d.log(slog.LevelWarn, "pagesplit skipped: zbarimg not found in PATH", "err", lookErr)
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
pageCount, err := d.pageCount(ctx, pdfPath)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("pagesplit: page count: %w", err)
|
||||
}
|
||||
if pageCount < 2 {
|
||||
return nil, false, nil
|
||||
}
|
||||
if pageCount > d.maxPages() {
|
||||
d.log(slog.LevelWarn, "pagesplit skipped: page count above limit",
|
||||
"file", pdfPath, "pages", pageCount, "max_pages", d.maxPages())
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
jobDir := filepath.Join(d.tmpDir(), "split-"+randomID())
|
||||
if mkErr := os.MkdirAll(jobDir, 0o750); mkErr != nil {
|
||||
return nil, false, fmt.Errorf("pagesplit: create scratch dir: %w", mkErr)
|
||||
}
|
||||
cleanup := func() { os.RemoveAll(jobDir) }
|
||||
// Anything below that returns without a successful split must not leak the
|
||||
// scratch directory; the success path hands cleanup to the caller instead.
|
||||
ok := false
|
||||
defer func() {
|
||||
if !ok {
|
||||
cleanup()
|
||||
}
|
||||
}()
|
||||
|
||||
sepPages, err := d.detectSeparatorPages(ctx, pdfPath, jobDir, pageCount)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("pagesplit: separator detection: %w", err)
|
||||
}
|
||||
if len(sepPages) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
ranges := contentRanges(pageCount, sepPages)
|
||||
if len(ranges) == 0 {
|
||||
// Pathological upload: only separator sheets, no content at all. Do not
|
||||
// silently discard it — archive the original unsplit so the operator
|
||||
// sees what was scanned.
|
||||
d.log(slog.LevelWarn, "pagesplit skipped: document consists of separator pages only",
|
||||
"file", pdfPath, "pages", pageCount)
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
partsDir := filepath.Join(jobDir, "parts")
|
||||
if mkErr := os.MkdirAll(partsDir, 0o750); mkErr != nil {
|
||||
return nil, false, fmt.Errorf("pagesplit: create parts dir: %w", mkErr)
|
||||
}
|
||||
var parts []string
|
||||
for i, rg := range ranges {
|
||||
partPath, perr := d.extractRange(ctx, pdfPath, partsDir, i+1, rg[0], rg[1])
|
||||
if perr != nil {
|
||||
// Partial split is never handed out — the caller falls back to
|
||||
// archiving the unsplit original.
|
||||
return nil, false, fmt.Errorf("pagesplit: extract pages %d-%d: %w", rg[0], rg[1], perr)
|
||||
}
|
||||
parts = append(parts, partPath)
|
||||
}
|
||||
|
||||
d.log(slog.LevelInfo, "pagesplit produced parts",
|
||||
"file", pdfPath, "pages", pageCount, "separator_pages", sepPages, "parts", len(parts))
|
||||
|
||||
ok = true
|
||||
return &Result{
|
||||
Parts: parts,
|
||||
PartPageRanges: ranges,
|
||||
SeparatorPages: sepPages,
|
||||
PageCount: pageCount,
|
||||
Cleanup: cleanup,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// IsSeparatorValue reports whether a decoded barcode payload marks a separator
|
||||
// page under this detector's marker configuration.
|
||||
func (d *Detector) IsSeparatorValue(value string) bool {
|
||||
v := strings.ToUpper(strings.TrimSpace(value))
|
||||
m := strings.ToUpper(strings.TrimSpace(d.marker()))
|
||||
if v == "" || m == "" {
|
||||
return false
|
||||
}
|
||||
if d.MarkerPrefix {
|
||||
return strings.HasPrefix(v, m)
|
||||
}
|
||||
return v == m
|
||||
}
|
||||
|
||||
var pdfinfoPagesRegex = regexp.MustCompile(`(?m)^Pages:\s+(\d+)`)
|
||||
|
||||
// pageCount reads the page count via `pdfinfo`.
|
||||
func (d *Detector) pageCount(ctx context.Context, pdfPath string) (int, error) {
|
||||
cctx, cancel := context.WithTimeout(ctx, d.timeout())
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(cctx, d.pdfinfoPath(), pdfPath)
|
||||
var out, stderr bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return 0, fmt.Errorf("pdfinfo failed: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
m := pdfinfoPagesRegex.FindStringSubmatch(out.String())
|
||||
if m == nil {
|
||||
return 0, fmt.Errorf("pdfinfo output had no Pages line")
|
||||
}
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("pdfinfo page count unparseable: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// pageNumRegex pulls the page number out of the filenames pdftoppm/pdfseparate
|
||||
// generate (page-01.png, page-1.png, seg-12.pdf, ...). Sorting on that number
|
||||
// rather than lexically matters as soon as a run crosses 9 or 99 pages.
|
||||
var pageNumRegex = regexp.MustCompile(`(\d+)\D*$`)
|
||||
|
||||
// detectSeparatorPages rasterizes every page once and decodes barcodes on it,
|
||||
// returning the 1-based page numbers that carry the marker.
|
||||
func (d *Detector) detectSeparatorPages(ctx context.Context, pdfPath, jobDir string, pageCount int) ([]int, error) {
|
||||
rasterDir := filepath.Join(jobDir, "raster")
|
||||
if err := os.MkdirAll(rasterDir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create raster dir: %w", err)
|
||||
}
|
||||
|
||||
cctx, cancel := context.WithTimeout(ctx, d.timeout())
|
||||
defer cancel()
|
||||
|
||||
prefix := filepath.Join(rasterDir, "page")
|
||||
cmd := exec.CommandContext(cctx, d.pdftoppmPath(),
|
||||
"-r", strconv.Itoa(d.rasterDPI()), "-png", pdfPath, prefix)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("pdftoppm failed: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
|
||||
pages, err := sortedNumberedFiles(rasterDir, ".png")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(pages) != pageCount {
|
||||
// Mismatch means the page-number mapping below cannot be trusted, and a
|
||||
// wrong mapping would cut the document in the wrong place — refuse.
|
||||
return nil, fmt.Errorf("rasterized %d pages but pdfinfo reported %d", len(pages), pageCount)
|
||||
}
|
||||
|
||||
var sep []int
|
||||
for i, page := range pages {
|
||||
codes, decErr := barcode.DecodeBarcodes(ctx, page)
|
||||
if decErr != nil {
|
||||
// Best-effort per page, exactly as in internal/ocr: a page whose
|
||||
// barcode pass errored is simply treated as a content page.
|
||||
continue
|
||||
}
|
||||
for _, code := range codes {
|
||||
if d.IsSeparatorValue(code) {
|
||||
sep = append(sep, i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return sep, nil
|
||||
}
|
||||
|
||||
// contentRanges turns a page count plus the separator page numbers into the
|
||||
// 1-based inclusive page ranges of the content segments, dropping the
|
||||
// separator pages themselves and any empty segment (two adjacent separator
|
||||
// sheets, or one at the very start/end).
|
||||
func contentRanges(pageCount int, sepPages []int) [][2]int {
|
||||
isSep := make(map[int]bool, len(sepPages))
|
||||
for _, p := range sepPages {
|
||||
isSep[p] = true
|
||||
}
|
||||
var ranges [][2]int
|
||||
start := 0
|
||||
for p := 1; p <= pageCount; p++ {
|
||||
if isSep[p] {
|
||||
if start != 0 {
|
||||
ranges = append(ranges, [2]int{start, p - 1})
|
||||
start = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
if start == 0 {
|
||||
start = p
|
||||
}
|
||||
}
|
||||
if start != 0 {
|
||||
ranges = append(ranges, [2]int{start, pageCount})
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
// extractRange writes pages [first,last] of pdfPath into one PDF under
|
||||
// partsDir, using pdfseparate (per-page extraction) plus pdfunite (re-merge)
|
||||
// — the poppler-only equivalent of `qpdf --pages`.
|
||||
func (d *Detector) extractRange(ctx context.Context, pdfPath, partsDir string, index, first, last int) (string, error) {
|
||||
segDir := filepath.Join(partsDir, fmt.Sprintf("seg-%03d", index))
|
||||
if err := os.MkdirAll(segDir, 0o750); err != nil {
|
||||
return "", fmt.Errorf("create segment dir: %w", err)
|
||||
}
|
||||
|
||||
sepCtx, cancelSep := context.WithTimeout(ctx, d.timeout())
|
||||
defer cancelSep()
|
||||
|
||||
pattern := filepath.Join(segDir, "p-%d.pdf")
|
||||
cmd := exec.CommandContext(sepCtx, d.pdfseparatePath(),
|
||||
"-f", strconv.Itoa(first), "-l", strconv.Itoa(last), pdfPath, pattern)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("pdfseparate failed: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
|
||||
pageFiles, err := sortedNumberedFiles(segDir, ".pdf")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
want := last - first + 1
|
||||
if len(pageFiles) != want {
|
||||
return "", fmt.Errorf("pdfseparate produced %d pages, expected %d", len(pageFiles), want)
|
||||
}
|
||||
if len(pageFiles) == 1 {
|
||||
// Single-page segment: the extracted page already IS the part.
|
||||
return pageFiles[0], nil
|
||||
}
|
||||
|
||||
uniteCtx, cancelUnite := context.WithTimeout(ctx, d.timeout())
|
||||
defer cancelUnite()
|
||||
|
||||
outPath := filepath.Join(partsDir, fmt.Sprintf("part-%03d.pdf", index))
|
||||
args := append(append([]string{}, pageFiles...), outPath)
|
||||
uniteCmd := exec.CommandContext(uniteCtx, d.pdfunitePath(), args...)
|
||||
var uniteErr bytes.Buffer
|
||||
uniteCmd.Stderr = &uniteErr
|
||||
if err := uniteCmd.Run(); err != nil {
|
||||
os.Remove(outPath)
|
||||
return "", fmt.Errorf("pdfunite failed: %w (%s)", err, strings.TrimSpace(uniteErr.String()))
|
||||
}
|
||||
if fi, statErr := os.Stat(outPath); statErr != nil || fi.Size() == 0 {
|
||||
os.Remove(outPath)
|
||||
return "", fmt.Errorf("pdfunite produced empty/missing output: %v", statErr)
|
||||
}
|
||||
return outPath, nil
|
||||
}
|
||||
|
||||
// sortedNumberedFiles lists dir's files with the given extension, sorted by
|
||||
// the trailing number in their name (numeric, not lexical).
|
||||
func sortedNumberedFiles(dir, ext string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read dir %s: %w", dir, err)
|
||||
}
|
||||
type numbered struct {
|
||||
path string
|
||||
num int
|
||||
}
|
||||
var found []numbered
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ext) {
|
||||
continue
|
||||
}
|
||||
num := 0
|
||||
base := strings.TrimSuffix(entry.Name(), ext)
|
||||
if m := pageNumRegex.FindStringSubmatch(base); m != nil {
|
||||
num, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
found = append(found, numbered{path: filepath.Join(dir, entry.Name()), num: num})
|
||||
}
|
||||
sort.Slice(found, func(i, j int) bool {
|
||||
if found[i].num != found[j].num {
|
||||
return found[i].num < found[j].num
|
||||
}
|
||||
return found[i].path < found[j].path
|
||||
})
|
||||
paths := make([]string, 0, len(found))
|
||||
for _, f := range found {
|
||||
paths = append(paths, f.path)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// randomID returns a random hex string for scratch directory names. Kept
|
||||
// dependency-free, same approach as internal/ocr.randomID.
|
||||
func randomID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("job-%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (d *Detector) log(level slog.Level, msg string, args ...any) {
|
||||
if d == nil || d.Logger == nil {
|
||||
return
|
||||
}
|
||||
d.Logger.Log(context.Background(), level, msg, args...)
|
||||
}
|
||||
|
||||
func (d *Detector) marker() string {
|
||||
if strings.TrimSpace(d.Marker) == "" {
|
||||
return DefaultMarker
|
||||
}
|
||||
return d.Marker
|
||||
}
|
||||
|
||||
func (d *Detector) pdftoppmPath() string { return orDefault(d.PdftoppmPath, "pdftoppm") }
|
||||
func (d *Detector) pdfinfoPath() string { return orDefault(d.PdfinfoPath, "pdfinfo") }
|
||||
func (d *Detector) pdfseparatePath() string { return orDefault(d.PdfseparatePath, "pdfseparate") }
|
||||
func (d *Detector) pdfunitePath() string { return orDefault(d.PdfunitePath, "pdfunite") }
|
||||
|
||||
func orDefault(v, def string) string {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (d *Detector) tmpDir() string {
|
||||
if strings.TrimSpace(d.TmpDir) == "" {
|
||||
return os.TempDir()
|
||||
}
|
||||
return d.TmpDir
|
||||
}
|
||||
|
||||
func (d *Detector) rasterDPI() int {
|
||||
if d.RasterDPI <= 0 {
|
||||
return defaultRasterDPI
|
||||
}
|
||||
return d.RasterDPI
|
||||
}
|
||||
|
||||
func (d *Detector) maxPages() int {
|
||||
if d.MaxPages <= 0 {
|
||||
return defaultMaxPages
|
||||
}
|
||||
return d.MaxPages
|
||||
}
|
||||
|
||||
func (d *Detector) timeout() time.Duration {
|
||||
if d.Timeout <= 0 {
|
||||
return defaultTimeout
|
||||
}
|
||||
return d.Timeout
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
// Package sftpserver implements an embedded, per-tenant SFTP server for
|
||||
// archivdms. Instead of provisioning real OS users + OpenSSH
|
||||
// ChrootDirectory per tenant, the server runs inside the archivdms binary
|
||||
// and enforces tenant isolation entirely in software:
|
||||
//
|
||||
// - Authentication is checked against the `sftp_credentials` table
|
||||
// (internal/storage/sftp_credentials.go), a narrow, independently
|
||||
// revocable credential — not a full user login.
|
||||
// - Once authenticated, a tenant is virtually "locked" into
|
||||
// `<storage.base_path>/inbox/<tenant_id>/`: the SFTP handlers only ever
|
||||
// resolve paths relative to that directory and reject any path that
|
||||
// would escape it (no OS-level chroot, no setuid, no real filesystem
|
||||
// jail — just careful path handling).
|
||||
// - A polling watcher goroutine picks up files dropped into that
|
||||
// directory and feeds them through the exact same
|
||||
// inbox->hash->store->OCR->DB pipeline as the HTTP upload endpoint
|
||||
// (see internal/api/document_handlers.go storeUploadedFile, exposed
|
||||
// here via the UploadFunc callback to avoid an import cycle between
|
||||
// internal/api and internal/sftpserver).
|
||||
package sftpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"archivdms/config"
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
// pollInterval is how often the watcher scans inbox directories for new
|
||||
// files dropped over SFTP. No fsnotify dependency — a simple polling loop is
|
||||
// good enough for this volume/latency profile (analogous to the project's
|
||||
// existing cron-style background jobs).
|
||||
const pollInterval = 5 * time.Second
|
||||
|
||||
// UploadFunc is the shared upload-pipeline entry point, implemented by
|
||||
// internal/api.Server.StoreUploadedFile. Kept as a function value (rather
|
||||
// than importing internal/api directly) to avoid an import cycle:
|
||||
// internal/api already imports internal/storage and internal/audit, and
|
||||
// wiring happens the other way around in cmd/archivdms/main.go.
|
||||
type UploadFunc func(ctx context.Context, tenantID int64, title, docType, correspondent string, file io.Reader, filename, contentType string) (*storage.Document, string, error)
|
||||
|
||||
// Server is the embedded per-tenant SFTP server.
|
||||
type Server struct {
|
||||
cfg config.SFTPConfig
|
||||
storageCfg config.StorageConfig
|
||||
store *storage.Store
|
||||
audlog *audit.Logger
|
||||
logger *slog.Logger
|
||||
upload UploadFunc
|
||||
|
||||
listener net.Listener
|
||||
sshCfg *ssh.ServerConfig
|
||||
|
||||
stopOnce sync.Once
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// New constructs an SFTP server. Call Start to begin listening and Stop to
|
||||
// shut down.
|
||||
func New(cfg config.SFTPConfig, storageCfg config.StorageConfig, store *storage.Store, audlog *audit.Logger, logger *slog.Logger, upload UploadFunc) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
storageCfg: storageCfg,
|
||||
store: store,
|
||||
audlog: audlog,
|
||||
logger: logger,
|
||||
upload: upload,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start loads/generates the host key, opens the listener, and launches the
|
||||
// accept loop plus the inbox watcher as background goroutines. It returns
|
||||
// once the listener is up (or an error occurred setting it up); the accept
|
||||
// loop itself keeps running in the background.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
signer, err := s.loadOrCreateHostKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("sftpserver: host key: %w", err)
|
||||
}
|
||||
|
||||
s.sshCfg = &ssh.ServerConfig{
|
||||
PasswordCallback: s.passwordCallback,
|
||||
}
|
||||
s.sshCfg.AddHostKey(signer)
|
||||
|
||||
bind := s.cfg.ResolvedBind()
|
||||
ln, err := net.Listen("tcp", bind)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sftpserver: listen %s: %w", bind, err)
|
||||
}
|
||||
s.listener = ln
|
||||
s.logger.Info("sftp server listening", "addr", bind)
|
||||
|
||||
go s.acceptLoop()
|
||||
go s.watchLoop(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop closes the listener, ending the accept loop, and signals the watcher
|
||||
// to exit.
|
||||
func (s *Server) Stop() {
|
||||
s.stopOnce.Do(func() {
|
||||
close(s.stopCh)
|
||||
if s.listener != nil {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- host key bootstrap ---
|
||||
|
||||
func (s *Server) loadOrCreateHostKey() (ssh.Signer, error) {
|
||||
path := s.cfg.ResolvedHostKeyPath(s.storageCfg.BasePath)
|
||||
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
return ssh.ParsePrivateKey(data)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("sftp host key not found, generating a new one", "path", path)
|
||||
key, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate host key: %w", err)
|
||||
}
|
||||
der := x509.MarshalPKCS1PrivateKey(key)
|
||||
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}
|
||||
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create host key dir: %w", err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(path, pem.EncodeToMemory(block), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write host key: %w", err)
|
||||
}
|
||||
return ssh.NewSignerFromKey(key)
|
||||
}
|
||||
|
||||
// --- authentication ---
|
||||
|
||||
func (s *Server) passwordCallback(meta ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||||
username := meta.User()
|
||||
ctx := context.Background()
|
||||
cred, err := s.store.VerifySFTPLogin(ctx, username, string(password))
|
||||
success := err == nil
|
||||
|
||||
detail := ""
|
||||
if err != nil {
|
||||
detail = err.Error()
|
||||
}
|
||||
var tenantID *int64
|
||||
if cred != nil {
|
||||
tenantID = &cred.TenantID
|
||||
}
|
||||
if s.audlog != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventSFTPLogin,
|
||||
Username: username,
|
||||
IPAddress: remoteIPFromConn(meta.RemoteAddr()),
|
||||
TenantID: tenantID,
|
||||
Success: success,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
if !success {
|
||||
return nil, fmt.Errorf("sftpserver: authentication failed")
|
||||
}
|
||||
|
||||
_ = s.store.TouchSFTPLastLogin(ctx, cred.ID)
|
||||
|
||||
return &ssh.Permissions{
|
||||
Extensions: map[string]string{
|
||||
"tenant_id": strconv.FormatInt(cred.TenantID, 10),
|
||||
"username": username,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func remoteIPFromConn(addr net.Addr) string {
|
||||
if addr == nil {
|
||||
return ""
|
||||
}
|
||||
host, _, err := net.SplitHostPort(addr.String())
|
||||
if err != nil {
|
||||
return addr.String()
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// --- accept loop ---
|
||||
|
||||
func (s *Server) acceptLoop() {
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
default:
|
||||
s.logger.Warn("sftp accept error", "err", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
go s.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleConn(conn net.Conn) {
|
||||
sshConn, chans, reqs, err := ssh.NewServerConn(conn, s.sshCfg)
|
||||
if err != nil {
|
||||
s.logger.Warn("sftp handshake failed", "err", err, "remote", conn.RemoteAddr())
|
||||
return
|
||||
}
|
||||
defer sshConn.Close()
|
||||
|
||||
tenantIDStr := sshConn.Permissions.Extensions["tenant_id"]
|
||||
tenantID, err := strconv.ParseInt(tenantIDStr, 10, 64)
|
||||
if err != nil {
|
||||
s.logger.Error("sftp connection missing tenant_id extension", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
go ssh.DiscardRequests(reqs)
|
||||
|
||||
for newChan := range chans {
|
||||
if newChan.ChannelType() != "session" {
|
||||
_ = newChan.Reject(ssh.UnknownChannelType, "unsupported channel type")
|
||||
continue
|
||||
}
|
||||
channel, requests, err := newChan.Accept()
|
||||
if err != nil {
|
||||
s.logger.Warn("sftp channel accept failed", "err", err)
|
||||
continue
|
||||
}
|
||||
go s.handleSession(channel, requests, tenantID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request, tenantID int64) {
|
||||
defer channel.Close()
|
||||
|
||||
for req := range requests {
|
||||
ok := req.Type == "subsystem" && string(req.Payload[4:]) == "sftp"
|
||||
if req.WantReply {
|
||||
_ = req.Reply(ok, nil)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
root := filepath.Join(s.storageCfg.InboxPath(), strconv.FormatInt(tenantID, 10))
|
||||
if err := os.MkdirAll(root, 0o750); err != nil {
|
||||
s.logger.Error("sftp: create tenant inbox dir failed", "tenant_id", tenantID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
fs := &tenantFS{root: root}
|
||||
handlers := sftp.Handlers{
|
||||
FileGet: fs,
|
||||
FilePut: fs,
|
||||
FileCmd: fs,
|
||||
FileList: fs,
|
||||
}
|
||||
server := sftp.NewRequestServer(channel, handlers)
|
||||
if err := server.Serve(); err != nil && err != io.EOF {
|
||||
s.logger.Warn("sftp session ended with error", "tenant_id", tenantID, "err", err)
|
||||
}
|
||||
_ = server.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// --- watcher: picks up files dropped into inbox/<tenant_id>/ and feeds them
|
||||
// through the shared upload pipeline ---
|
||||
|
||||
func (s *Server) watchLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(pollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.scanInbox(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) scanInbox(ctx context.Context) {
|
||||
base := s.storageCfg.InboxPath()
|
||||
tenantDirs, err := os.ReadDir(base)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
s.logger.Warn("sftp watcher: read inbox root failed", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for _, td := range tenantDirs {
|
||||
if !td.IsDir() {
|
||||
continue
|
||||
}
|
||||
tenantID, err := strconv.ParseInt(td.Name(), 10, 64)
|
||||
if err != nil {
|
||||
continue // not a tenant directory (e.g. stray file), skip
|
||||
}
|
||||
s.scanTenantInbox(ctx, tenantID, filepath.Join(base, td.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) scanTenantInbox(ctx context.Context, tenantID int64, dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
s.logger.Warn("sftp watcher: read tenant inbox failed", "tenant_id", tenantID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
s.processInboxFile(ctx, tenantID, path, e.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) processInboxFile(ctx context.Context, tenantID int64, path, filename string) {
|
||||
// Skip files still being written (e.g. an in-progress SFTP PUT). A
|
||||
// simple heuristic: if the file's mtime is very recent, give the next
|
||||
// poll cycle a chance to see it settle instead of processing a partial
|
||||
// upload.
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return // vanished since ReadDir, e.g. concurrent processing
|
||||
}
|
||||
if time.Since(info.ModTime()) < pollInterval {
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
s.logger.Warn("sftp watcher: open inbox file failed", "path", path, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
title := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||
doc, warn, err := s.upload(ctx, tenantID, title, "", "", f, filename, "")
|
||||
f.Close()
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicateContentHash) {
|
||||
s.logger.Info("sftp watcher: duplicate content, discarding", "path", path)
|
||||
} else {
|
||||
s.logger.Error("sftp watcher: upload pipeline failed", "path", path, "err", err)
|
||||
if s.audlog != nil {
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentCreate, Username: "sftp:tenant-" + strconv.FormatInt(tenantID, 10), TenantID: &tenantID, Success: false, Detail: err.Error()})
|
||||
}
|
||||
return // leave the file in place for a retry on the next cycle
|
||||
}
|
||||
} else {
|
||||
s.logger.Info("sftp watcher: document created", "document_id", doc.ID, "path", path)
|
||||
if warn != "" && s.logger != nil {
|
||||
s.logger.Warn("sftp watcher: upload succeeded with warning", "document_id", doc.ID, "warn", warn)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the original SFTP-dropped file: storeUploadedFile writes its own
|
||||
// copy into inbox/<tenant>/<random>.<ext> and moves *that* into store/, so
|
||||
// this original drop file is no longer needed either way (processed or
|
||||
// confirmed duplicate).
|
||||
if err := os.Remove(path); err != nil {
|
||||
s.logger.Warn("sftp watcher: cleanup of inbox file failed", "path", path, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package sftpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
)
|
||||
|
||||
// tenantFS implements the four github.com/pkg/sftp request-server
|
||||
// interfaces (FileReader/FileWriter/FileCmder/FileLister) on top of a single
|
||||
// real directory (root) — the authenticated tenant's
|
||||
// inbox/<tenant_id>/ folder.
|
||||
//
|
||||
// This is the "virtual chroot": every incoming SFTP path is resolved
|
||||
// relative to root and validated to never escape it (no "..", no absolute
|
||||
// paths pointing elsewhere). v1 intentionally supports only a flat
|
||||
// directory — no subfolder create/navigate/delete — which keeps the path
|
||||
// validation trivial: a request path may only name a direct child of root.
|
||||
type tenantFS struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// resolve maps a virtual SFTP path ("/", "/foo.pdf", ...) onto a real path
|
||||
// under fs.root, rejecting anything that isn't a direct child of the root
|
||||
// (blocks path traversal and subfolder use in one check).
|
||||
func (fs *tenantFS) resolve(virtual string) (string, error) {
|
||||
clean := filepath.Clean("/" + virtual)
|
||||
if clean == "/" {
|
||||
return fs.root, nil
|
||||
}
|
||||
clean = strings.TrimPrefix(clean, "/")
|
||||
if strings.Contains(clean, "/") || clean == ".." || clean == "." {
|
||||
return "", errors.New("sftpserver: path escapes tenant root or is not a direct child")
|
||||
}
|
||||
return filepath.Join(fs.root, clean), nil
|
||||
}
|
||||
|
||||
// Fileread implements sftp.FileReader (GET). Reading back an already
|
||||
// uploaded-but-not-yet-processed file is allowed (harmless), but there is
|
||||
// nothing to read once the watcher has moved the file into store/ (by
|
||||
// design — inbox/ is a transient staging area, not a browsable archive).
|
||||
func (fs *tenantFS) Fileread(r *sftp.Request) (io.ReaderAt, error) {
|
||||
path, err := fs.resolve(r.Filepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Filewrite implements sftp.FileWriter (PUT). New files are created at the
|
||||
// root of the tenant's inbox only; existing files may not be overwritten
|
||||
// (O_EXCL) to avoid a client silently clobbering a file the watcher hasn't
|
||||
// picked up yet.
|
||||
func (fs *tenantFS) Filewrite(r *sftp.Request) (io.WriterAt, error) {
|
||||
path, err := fs.resolve(r.Filepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Filecmd implements sftp.FileCmder for out-of-band filesystem operations
|
||||
// (Remove, Rename, Mkdir, Setstat, ...). v1 deliberately supports none of
|
||||
// these beyond what's needed for a plain "put a file" workflow — clients
|
||||
// get a clean permission error rather than silently succeeding.
|
||||
func (fs *tenantFS) Filecmd(r *sftp.Request) error {
|
||||
return errors.New("sftpserver: operation not permitted (only uploading new files is supported)")
|
||||
}
|
||||
|
||||
// Filelist implements sftp.FileLister (LIST/STAT). Listing the root shows
|
||||
// the tenant's pending (not-yet-watched) inbox files; anything else is
|
||||
// rejected by resolve.
|
||||
func (fs *tenantFS) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
|
||||
switch r.Method {
|
||||
case "List":
|
||||
path, err := fs.resolve(r.Filepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
infos := make([]os.FileInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
infos = append(infos, info)
|
||||
}
|
||||
return listerAt(infos), nil
|
||||
case "Stat", "Lstat":
|
||||
path, err := fs.resolve(r.Filepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return listerAt([]os.FileInfo{info}), nil
|
||||
default:
|
||||
return nil, errors.New("sftpserver: unsupported list method " + r.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// listerAt is the minimal []os.FileInfo -> sftp.ListerAt adapter expected by
|
||||
// github.com/pkg/sftp's request server.
|
||||
type listerAt []os.FileInfo
|
||||
|
||||
func (l listerAt) ListAt(dst []os.FileInfo, offset int64) (int, error) {
|
||||
if offset >= int64(len(l)) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(dst, l[offset:])
|
||||
if n < len(dst) {
|
||||
return n, io.EOF
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// Per-tenant API keys for the read-only Buchhaltungs-Pull-API (see
|
||||
// migrations/026_accounting_api_keys.sql and
|
||||
// internal/api/accounting_handlers.go).
|
||||
//
|
||||
// Token handling follows exactly the share-link pattern in shares.go: the raw
|
||||
// key is generated once (32 bytes crypto/rand, base64url, with a fixed
|
||||
// "adms_" prefix so it is recognisable in logs/config files), returned to the
|
||||
// caller EXACTLY once at creation time, and only its hex SHA-256 hash is ever
|
||||
// persisted. Lookup for authentication is always by key_hash, never by id.
|
||||
//
|
||||
// Keys are never hard-deleted: revoking only sets revoked_at, so the audit
|
||||
// trail of which key pulled which documents stays resolvable (GoBD
|
||||
// Nachvollziehbarkeit).
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// accountingKeyPrefix marks a raw accounting API key as such. It is part of
|
||||
// the hashed value (the whole string is hashed), it is NOT a separate column.
|
||||
const accountingKeyPrefix = "adms_"
|
||||
|
||||
// ErrAccountingKeyNotFound is returned when a key lookup (by id+tenant or by
|
||||
// key_hash) matches no usable row — unknown key, wrong tenant, or revoked.
|
||||
var ErrAccountingKeyNotFound = errors.New("storage: accounting api key not found")
|
||||
|
||||
// AccountingAPIKey is the safe view of an accounting_api_keys row. The hash is
|
||||
// deliberately NOT part of this struct so it can never be serialised into an
|
||||
// API response, and the plaintext key exists only as the second return value
|
||||
// of CreateAccountingAPIKey.
|
||||
type AccountingAPIKey struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
Label string `json:"label"`
|
||||
CreatedBy *int64 `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Store) initAccountingAPIKeysSchema(ctx context.Context) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
-- No FK on tenant_id / created_by: consistent with the rest of the
|
||||
-- schema (plain BIGINT), because tenants/users are owned by other
|
||||
-- stores that initialise after storage.New().
|
||||
CREATE TABLE IF NOT EXISTS accounting_api_keys (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
created_by BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revoked_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounting_api_keys_tenant ON accounting_api_keys(tenant_id);
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: create accounting_api_keys table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashAccountingKey returns the hex-encoded SHA-256 of a raw accounting API
|
||||
// key, the value persisted in / looked up from accounting_api_keys.key_hash.
|
||||
func hashAccountingKey(key string) string {
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// CreateAccountingAPIKey inserts a new key for a tenant and returns the stored
|
||||
// row plus the raw (plaintext) key. The plaintext is returned ONLY here and
|
||||
// never again — only its SHA-256 hash is persisted.
|
||||
func (s *Store) CreateAccountingAPIKey(ctx context.Context, tenantID int64, label string, createdBy *int64) (*AccountingAPIKey, string, error) {
|
||||
rawBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(rawBytes); err != nil {
|
||||
return nil, "", fmt.Errorf("storage: generate accounting api key: %w", err)
|
||||
}
|
||||
key := accountingKeyPrefix + base64.RawURLEncoding.EncodeToString(rawBytes)
|
||||
|
||||
var k AccountingAPIKey
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO accounting_api_keys (tenant_id, key_hash, label, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, tenant_id, label, created_by, created_at, revoked_at, last_used_at
|
||||
`, tenantID, hashAccountingKey(key), label, createdBy,
|
||||
).Scan(&k.ID, &k.TenantID, &k.Label, &k.CreatedBy, &k.CreatedAt, &k.RevokedAt, &k.LastUsedAt)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("storage: create accounting api key: %w", err)
|
||||
}
|
||||
return &k, key, nil
|
||||
}
|
||||
|
||||
// ResolveAccountingAPIKey authenticates a raw key: it hashes the key, looks the
|
||||
// row up by key_hash, rejects revoked keys, refreshes last_used_at and returns
|
||||
// the owning tenant id plus the key id.
|
||||
//
|
||||
// The returned tenantID is THE ONLY trusted tenant source for the pull
|
||||
// endpoints — no caller may take a tenant_id from the request itself.
|
||||
// Unknown and revoked keys both yield ErrAccountingKeyNotFound so the caller
|
||||
// cannot distinguish them.
|
||||
func (s *Store) ResolveAccountingAPIKey(ctx context.Context, rawKey string) (tenantID int64, keyID int64, err error) {
|
||||
if rawKey == "" {
|
||||
return 0, 0, ErrAccountingKeyNotFound
|
||||
}
|
||||
// Single statement: authenticate + touch last_used_at atomically. The
|
||||
// revoked_at IS NULL guard lives in the WHERE clause, so a revoked key can
|
||||
// never return a tenant id.
|
||||
err = s.db.QueryRow(ctx, `
|
||||
UPDATE accounting_api_keys
|
||||
SET last_used_at = now()
|
||||
WHERE key_hash = $1 AND revoked_at IS NULL
|
||||
RETURNING tenant_id, id
|
||||
`, hashAccountingKey(rawKey)).Scan(&tenantID, &keyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, 0, ErrAccountingKeyNotFound
|
||||
}
|
||||
return 0, 0, fmt.Errorf("storage: resolve accounting api key: %w", err)
|
||||
}
|
||||
return tenantID, keyID, nil
|
||||
}
|
||||
|
||||
// ListAccountingAPIKeys returns all keys of a tenant (including revoked ones —
|
||||
// no hard delete), newest first. Never returns the hash or the plaintext.
|
||||
func (s *Store) ListAccountingAPIKeys(ctx context.Context, tenantID int64) ([]AccountingAPIKey, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, tenant_id, label, created_by, created_at, revoked_at, last_used_at
|
||||
FROM accounting_api_keys
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: list accounting api keys: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]AccountingAPIKey, 0)
|
||||
for rows.Next() {
|
||||
var k AccountingAPIKey
|
||||
if err := rows.Scan(&k.ID, &k.TenantID, &k.Label, &k.CreatedBy, &k.CreatedAt, &k.RevokedAt, &k.LastUsedAt); err != nil {
|
||||
return nil, fmt.Errorf("storage: scan accounting api key: %w", err)
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RevokeAccountingAPIKey marks a key as revoked (never hard-deleted), scoped to
|
||||
// tenant ownership (IDOR guard: id AND tenant_id). Returns
|
||||
// ErrAccountingKeyNotFound when no key of that id belongs to the tenant.
|
||||
func (s *Store) RevokeAccountingAPIKey(ctx context.Context, id, tenantID int64) error {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE accounting_api_keys SET revoked_at = now()
|
||||
WHERE id = $1 AND tenant_id = $2 AND revoked_at IS NULL
|
||||
`, id, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: revoke accounting api key: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrAccountingKeyNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ErrAkteNotFound is returned when a tenant-scoped akte lookup/update affects
|
||||
// zero rows (wrong id or wrong tenant).
|
||||
var ErrAkteNotFound = errors.New("storage: akte not found")
|
||||
|
||||
// Akte is a digital file folder ("digitaler Aktenordner") grouping documents
|
||||
// in a strict 1:n relationship via documents.akte_id. It has NO own ACL — an
|
||||
// akte's visibility is derived from the documents it contains (see
|
||||
// ListAkteDocuments, which applies the exact same document_visibility EXISTS
|
||||
// clause as ListDocuments). See project_akte_konzept_plan.md.
|
||||
type Akte struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
Titel string `json:"titel"`
|
||||
Beschreibung string `json:"beschreibung"`
|
||||
Status string `json:"status"`
|
||||
CorrespondentID *int64 `json:"correspondent_id,omitempty"`
|
||||
CreatedBy *int64 `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
||||
// DocumentCount is a computed field (COUNT of documents with this akte_id),
|
||||
// not a stored column. Populated by ListAkten/GetAkte for the list view.
|
||||
DocumentCount int `json:"document_count"`
|
||||
}
|
||||
|
||||
// initAktenSchema creates the akten table and adds the documents.akte_id FK
|
||||
// column. Order matters: the akten table must exist BEFORE the ALTER TABLE on
|
||||
// documents because akte_id references akten(id). Idempotent. Wired into
|
||||
// (*Store).initSchema (documents.go) after the other schema hooks.
|
||||
func (s *Store) initAktenSchema(ctx context.Context) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS akten (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
titel TEXT NOT NULL,
|
||||
beschreibung TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'offen' CHECK (status IN ('offen','geschlossen')),
|
||||
correspondent_id BIGINT REFERENCES correspondents(id),
|
||||
created_by BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
closed_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_akten_tenant ON akten (tenant_id);
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: create akten table: %w", err)
|
||||
}
|
||||
|
||||
// documents.akte_id: strict 1:n container link. ON DELETE SET NULL is the
|
||||
// GoBD safeguard — deleting an akte can NEVER delete documents, it only
|
||||
// decouples them (see project_akte_konzept_plan.md).
|
||||
_, err = s.db.Exec(ctx, `
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS akte_id BIGINT REFERENCES akten(id) ON DELETE SET NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: alter documents add akte_id: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateAkte inserts a new akte and returns it (document_count is 0 for a
|
||||
// freshly created akte).
|
||||
func (s *Store) CreateAkte(ctx context.Context, tenantID int64, titel, beschreibung string, correspondentID *int64, createdBy int64) (*Akte, error) {
|
||||
var a Akte
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO akten (tenant_id, titel, beschreibung, correspondent_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, tenant_id, titel, beschreibung, status, correspondent_id, created_by, created_at, closed_at
|
||||
`, tenantID, titel, beschreibung, correspondentID, createdBy).Scan(
|
||||
&a.ID, &a.TenantID, &a.Titel, &a.Beschreibung, &a.Status, &a.CorrespondentID, &a.CreatedBy, &a.CreatedAt, &a.ClosedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: create akte: %w", err)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// ListAkten returns all akten for a tenant, newest first, each with its
|
||||
// document_count. No ACL filter here: an akte itself is metadata; its contained
|
||||
// documents are ACL-filtered at ListAkteDocuments time.
|
||||
func (s *Store) ListAkten(ctx context.Context, tenantID int64) ([]Akte, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT a.id, a.tenant_id, a.titel, a.beschreibung, a.status, a.correspondent_id, a.created_by, a.created_at, a.closed_at,
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.akte_id = a.id AND d.deleted_at IS NULL) AS document_count
|
||||
FROM akten a
|
||||
WHERE a.tenant_id = $1
|
||||
ORDER BY a.created_at DESC
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: list akten: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]Akte, 0)
|
||||
for rows.Next() {
|
||||
var a Akte
|
||||
if err := rows.Scan(&a.ID, &a.TenantID, &a.Titel, &a.Beschreibung, &a.Status, &a.CorrespondentID, &a.CreatedBy, &a.CreatedAt, &a.ClosedAt, &a.DocumentCount); err != nil {
|
||||
return nil, fmt.Errorf("storage: scan akte: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetAkte retrieves a single akte by id, scoped to tenant, with document_count.
|
||||
// Returns ErrAkteNotFound when no row matches.
|
||||
func (s *Store) GetAkte(ctx context.Context, id, tenantID int64) (*Akte, error) {
|
||||
var a Akte
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT a.id, a.tenant_id, a.titel, a.beschreibung, a.status, a.correspondent_id, a.created_by, a.created_at, a.closed_at,
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.akte_id = a.id AND d.deleted_at IS NULL) AS document_count
|
||||
FROM akten a
|
||||
WHERE a.id = $1 AND a.tenant_id = $2
|
||||
`, id, tenantID).Scan(&a.ID, &a.TenantID, &a.Titel, &a.Beschreibung, &a.Status, &a.CorrespondentID, &a.CreatedBy, &a.CreatedAt, &a.ClosedAt, &a.DocumentCount)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrAkteNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("storage: get akte: %w", err)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// UpdateAkte changes an akte's titel/beschreibung/correspondent, scoped to
|
||||
// tenant. Returns ErrAkteNotFound when no row matches.
|
||||
func (s *Store) UpdateAkte(ctx context.Context, id, tenantID int64, titel, beschreibung string, correspondentID *int64) error {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE akten SET titel = $1, beschreibung = $2, correspondent_id = $3
|
||||
WHERE id = $4 AND tenant_id = $5
|
||||
`, titel, beschreibung, correspondentID, id, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: update akte: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrAkteNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseAkte marks an akte as geschlossen and stamps closed_at, scoped to
|
||||
// tenant. Returns ErrAkteNotFound when no row matches.
|
||||
func (s *Store) CloseAkte(ctx context.Context, id, tenantID int64) error {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE akten SET status = 'geschlossen', closed_at = now()
|
||||
WHERE id = $1 AND tenant_id = $2
|
||||
`, id, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: close akte: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrAkteNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAkte hard-deletes an akte, scoped to tenant. Documents are NOT deleted:
|
||||
// the ON DELETE SET NULL FK on documents.akte_id automatically decouples them.
|
||||
// Returns ErrAkteNotFound when no row matches.
|
||||
func (s *Store) DeleteAkte(ctx context.Context, id, tenantID int64) error {
|
||||
tag, err := s.db.Exec(ctx, `DELETE FROM akten WHERE id = $1 AND tenant_id = $2`, id, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: delete akte: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrAkteNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAkteDocuments returns all documents assigned to an akte, ACL-filtered
|
||||
// exactly like ListDocuments (aclUserID non-nil for role 'user', nil for
|
||||
// domain_admin/superadmin). Same document_visibility EXISTS clause plus the
|
||||
// created_by ownership fallback.
|
||||
func (s *Store) ListAkteDocuments(ctx context.Context, akteID, tenantID int64, aclUserID *int64) ([]Document, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, tenant_id, title, COALESCE(doc_type, ''), COALESCE(correspondent, ''), doc_type_id, correspondent_id, storage_path, content_hash,
|
||||
COALESCE(ocr_text, ''), retain_until, COALESCE(source, ''), COALESCE(source_ref, ''), created_by, title_manually_set, created_at, updated_at
|
||||
FROM documents
|
||||
WHERE tenant_id = $1 AND akte_id = $2 AND deleted_at IS NULL
|
||||
AND ($3::bigint IS NULL OR documents.created_by = $3 OR EXISTS (
|
||||
SELECT 1 FROM document_visibility dv
|
||||
JOIN permission_group_members pgm ON pgm.group_id = dv.group_id
|
||||
WHERE dv.document_id = documents.id AND pgm.user_id = $3
|
||||
))
|
||||
ORDER BY created_at DESC
|
||||
`, tenantID, akteID, aclUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: list akte documents: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]Document, 0)
|
||||
for rows.Next() {
|
||||
var d Document
|
||||
if err := rows.Scan(&d.ID, &d.TenantID, &d.Title, &d.DocType, &d.Correspondent, &d.DocTypeID, &d.CorrespondentID, &d.StoragePath, &d.ContentHash,
|
||||
&d.OCRText, &d.RetainUntil, &d.Source, &d.SourceRef, &d.CreatedBy, &d.TitleManuallySet, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("storage: scan akte document: %w", err)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetDocumentAkte assigns (akteID non-nil) or removes (akteID nil) a document's
|
||||
// akte membership, scoped to tenant. Analogous to SetDocumentDocType, but the
|
||||
// akte is not part of the document ACL, so no visibility recompute is needed —
|
||||
// only a search-index re-sync. Returns ErrDocumentNotFound when no row matches.
|
||||
func (s *Store) SetDocumentAkte(ctx context.Context, documentID, tenantID int64, akteID *int64) error {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE documents SET akte_id = $1, updated_at = now()
|
||||
WHERE id = $2 AND tenant_id = $3
|
||||
`, akteID, documentID, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: set document akte: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrDocumentNotFound
|
||||
}
|
||||
s.SyncIndex(ctx, documentID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// ErrClassificationTemplateNotFound is returned when a classification-template
|
||||
// lookup, update or delete does not match any row owned by the caller's tenant.
|
||||
var ErrClassificationTemplateNotFound = errors.New("storage: classification template not found or not owned by tenant")
|
||||
|
||||
// ErrDuplicateTemplateName is returned when a tenant already has a
|
||||
// classification template with the same name (UNIQUE(tenant_id, name)).
|
||||
var ErrDuplicateTemplateName = errors.New("storage: classification template with this name already exists for tenant")
|
||||
|
||||
// ClassificationTemplate is a tenant-scoped "Klassifizierungsvorlage": a named
|
||||
// bundle of a document type, tags, custom-field default values and a retention
|
||||
// period that can be applied to a document in one action. Deliberately NOT
|
||||
// persistently coupled to any document (no template_id column on documents) so
|
||||
// a later template edit can never retroactively change past documents — the
|
||||
// application is recorded only via an audit-log entry (GoBD-Nachvollziehbarkeit).
|
||||
type ClassificationTemplate struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DocTypeID *int64 `json:"doc_type_id,omitempty"`
|
||||
RetainYears *int `json:"retain_years,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
CreatedBy *int64 `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Tags []TaxonomyEntity `json:"tags"`
|
||||
FieldDefaults []TemplateFieldDefault `json:"field_defaults"`
|
||||
|
||||
// TitleTemplate is an optional Go text/template pattern used to derive the
|
||||
// document title when this template is applied (see
|
||||
// classification_templates_title.go). NULL/empty means "no template title"
|
||||
// — the tenant-wide default_title_template is tried next, and if that is
|
||||
// also empty the document's existing title is kept.
|
||||
TitleTemplate *string `json:"title_template,omitempty"`
|
||||
}
|
||||
|
||||
// TemplateFieldDefault is one resolved custom-field default of a template,
|
||||
// joined to its field definition. Exactly one value column is expected to be
|
||||
// populated (matching the field's type). overwrite carries the template's
|
||||
// intent to overwrite an already-set document value (only honoured on an
|
||||
// explicit confirmed apply — see ApplyTemplate).
|
||||
type TemplateFieldDefault struct {
|
||||
FieldID int64 `json:"field_id"`
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
FieldType string `json:"field_type"`
|
||||
ValueText *string `json:"value_text,omitempty"`
|
||||
ValueNumber *float64 `json:"value_number,omitempty"`
|
||||
ValueDate *time.Time `json:"value_date,omitempty"`
|
||||
ValueBool *bool `json:"value_bool,omitempty"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
// TemplateFieldDefaultInput is one supplied default in a bulk PUT. Exactly one
|
||||
// of the value pointers is expected to be populated (matching the field type).
|
||||
type TemplateFieldDefaultInput struct {
|
||||
FieldID int64 `json:"field_id"`
|
||||
ValueText *string `json:"value_text,omitempty"`
|
||||
ValueNumber *float64 `json:"value_number,omitempty"`
|
||||
ValueDate *string `json:"value_date,omitempty"` // ISO date "2006-01-02"
|
||||
ValueBool *bool `json:"value_bool,omitempty"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
// CreateTemplateRequest holds create parameters for a classification template.
|
||||
type CreateTemplateRequest struct {
|
||||
Name string
|
||||
Description string
|
||||
DocTypeID *int64
|
||||
RetainYears *int
|
||||
Active bool
|
||||
CreatedBy *int64
|
||||
TitleTemplate *string
|
||||
}
|
||||
|
||||
// UpdateTemplateRequest holds update parameters for a classification template.
|
||||
type UpdateTemplateRequest struct {
|
||||
Name string
|
||||
Description string
|
||||
DocTypeID *int64
|
||||
RetainYears *int
|
||||
Active bool
|
||||
TitleTemplate *string
|
||||
}
|
||||
|
||||
// initClassificationTemplatesSchema creates the classification_templates /
|
||||
// classification_template_tags / classification_template_field_defaults tables.
|
||||
// Idempotent, called from (*Store).initSchema AFTER initTaxonomySchema and
|
||||
// initCustomFieldsSchema (FK dependency on document_types / custom_field_defs).
|
||||
// Documented (not executed) in migrations/011_classification_templates.sql.
|
||||
func (s *Store) initClassificationTemplatesSchema(ctx context.Context) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS classification_templates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
doc_type_id BIGINT REFERENCES document_types(id) ON DELETE SET NULL,
|
||||
retain_years INT,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_by BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(tenant_id, name)
|
||||
);
|
||||
ALTER TABLE classification_templates ADD COLUMN IF NOT EXISTS title_template TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_templates_tenant ON classification_templates(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_templates_doc_type ON classification_templates(doc_type_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classification_template_tags (
|
||||
template_id BIGINT NOT NULL REFERENCES classification_templates(id) ON DELETE CASCADE,
|
||||
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (template_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classification_template_field_defaults (
|
||||
template_id BIGINT NOT NULL REFERENCES classification_templates(id) ON DELETE CASCADE,
|
||||
field_id BIGINT NOT NULL REFERENCES custom_field_defs(id) ON DELETE CASCADE,
|
||||
value_text TEXT,
|
||||
value_number NUMERIC,
|
||||
value_date DATE,
|
||||
value_bool BOOLEAN,
|
||||
overwrite BOOLEAN NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (template_id, field_id)
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: create classification templates tables: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanClassificationTemplate(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (*ClassificationTemplate, error) {
|
||||
var t ClassificationTemplate
|
||||
if err := row.Scan(&t.ID, &t.TenantID, &t.Name, &t.Description, &t.DocTypeID, &t.RetainYears,
|
||||
&t.Active, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt, &t.TitleTemplate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Tags = make([]TaxonomyEntity, 0)
|
||||
t.FieldDefaults = make([]TemplateFieldDefault, 0)
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
const classificationTemplateCols = `id, tenant_id, name, COALESCE(description, ''), doc_type_id, retain_years, active, created_by, created_at, updated_at, title_template`
|
||||
|
||||
// CreateTemplate inserts a new classification template (without tags / field
|
||||
// defaults — those are set via SetTemplateTags / SetTemplateFieldDefaults).
|
||||
func (s *Store) CreateTemplate(ctx context.Context, tenantID int64, req CreateTemplateRequest) (*ClassificationTemplate, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
INSERT INTO classification_templates (tenant_id, name, description, doc_type_id, retain_years, active, created_by, title_template)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING `+classificationTemplateCols,
|
||||
tenantID, req.Name, nullIfEmpty(req.Description), req.DocTypeID, req.RetainYears, req.Active, req.CreatedBy, nullIfEmptyPtr(req.TitleTemplate))
|
||||
t, err := scanClassificationTemplate(row)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return nil, ErrDuplicateTemplateName
|
||||
}
|
||||
return nil, fmt.Errorf("storage: create classification template: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ListTemplates returns all classification templates for a tenant, optionally
|
||||
// filtered by document type. Returns a non-nil (possibly empty) slice.
|
||||
func (s *Store) ListTemplates(ctx context.Context, tenantID int64, docTypeID *int64) ([]ClassificationTemplate, error) {
|
||||
query := `SELECT ` + classificationTemplateCols + ` FROM classification_templates WHERE tenant_id = $1`
|
||||
args := []any{tenantID}
|
||||
if docTypeID != nil {
|
||||
query += ` AND doc_type_id = $2`
|
||||
args = append(args, *docTypeID)
|
||||
}
|
||||
query += ` ORDER BY name ASC`
|
||||
rows, err := s.db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: list classification templates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]ClassificationTemplate, 0)
|
||||
for rows.Next() {
|
||||
t, err := scanClassificationTemplate(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: scan classification template: %w", err)
|
||||
}
|
||||
out = append(out, *t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetTemplate returns one classification template resolved with its tags and
|
||||
// custom-field defaults, scoped to tenant ownership.
|
||||
func (s *Store) GetTemplate(ctx context.Context, id, tenantID int64) (*ClassificationTemplate, error) {
|
||||
row := s.db.QueryRow(ctx, `SELECT `+classificationTemplateCols+`
|
||||
FROM classification_templates WHERE id = $1 AND tenant_id = $2`, id, tenantID)
|
||||
t, err := scanClassificationTemplate(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrClassificationTemplateNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("storage: get classification template: %w", err)
|
||||
}
|
||||
|
||||
tags, err := s.listTemplateTags(ctx, id, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Tags = tags
|
||||
|
||||
defaults, err := s.listTemplateFieldDefaults(ctx, id, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.FieldDefaults = defaults
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// listTemplateTags returns the tags attached to a template, scoped to tenant.
|
||||
func (s *Store) listTemplateTags(ctx context.Context, templateID, tenantID int64) ([]TaxonomyEntity, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT t.id, t.tenant_id, t.name, COALESCE(t.color, ''), t.match_algorithm, COALESCE(t.match_pattern, ''), t.case_sensitive, COALESCE(t.barcode_value, ''), t.created_at
|
||||
FROM tags t
|
||||
JOIN classification_template_tags ctt ON ctt.tag_id = t.id
|
||||
WHERE ctt.template_id = $1 AND t.tenant_id = $2
|
||||
ORDER BY t.name ASC
|
||||
`, templateID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: list template tags: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]TaxonomyEntity, 0)
|
||||
for rows.Next() {
|
||||
e, err := scanTaxonomyEntity(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: scan template tag: %w", err)
|
||||
}
|
||||
out = append(out, *e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// listTemplateFieldDefaults returns the custom-field defaults of a template,
|
||||
// joined to their definitions, scoped to tenant.
|
||||
func (s *Store) listTemplateFieldDefaults(ctx context.Context, templateID, tenantID int64) ([]TemplateFieldDefault, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT d.field_id, f.name, f.label, f.field_type,
|
||||
d.value_text, d.value_number, d.value_date, d.value_bool, d.overwrite
|
||||
FROM classification_template_field_defaults d
|
||||
JOIN custom_field_defs f ON f.id = d.field_id
|
||||
WHERE d.template_id = $1 AND f.tenant_id = $2
|
||||
ORDER BY f.name ASC
|
||||
`, templateID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: list template field defaults: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]TemplateFieldDefault, 0)
|
||||
for rows.Next() {
|
||||
var d TemplateFieldDefault
|
||||
if err := rows.Scan(&d.FieldID, &d.Name, &d.Label, &d.FieldType,
|
||||
&d.ValueText, &d.ValueNumber, &d.ValueDate, &d.ValueBool, &d.Overwrite); err != nil {
|
||||
return nil, fmt.Errorf("storage: scan template field default: %w", err)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateTemplate updates a classification template's core attributes (not its
|
||||
// tags / field defaults), scoped to tenant ownership.
|
||||
func (s *Store) UpdateTemplate(ctx context.Context, id, tenantID int64, req UpdateTemplateRequest) error {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE classification_templates
|
||||
SET name = $1, description = $2, doc_type_id = $3, retain_years = $4, active = $5, title_template = $6, updated_at = now()
|
||||
WHERE id = $7 AND tenant_id = $8
|
||||
`, req.Name, nullIfEmpty(req.Description), req.DocTypeID, req.RetainYears, req.Active, nullIfEmptyPtr(req.TitleTemplate), id, tenantID)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return ErrDuplicateTemplateName
|
||||
}
|
||||
return fmt.Errorf("storage: update classification template: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrClassificationTemplateNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTemplate deletes a classification template (cascades to its tags /
|
||||
// field defaults), scoped to tenant ownership.
|
||||
func (s *Store) DeleteTemplate(ctx context.Context, id, tenantID int64) error {
|
||||
tag, err := s.db.Exec(ctx, `DELETE FROM classification_templates WHERE id = $1 AND tenant_id = $2`, id, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: delete classification template: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrClassificationTemplateNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// templateOwned returns true if the template belongs to the tenant.
|
||||
func (s *Store) templateOwned(ctx context.Context, templateID, tenantID int64) (bool, error) {
|
||||
var ok bool
|
||||
err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM classification_templates WHERE id = $1 AND tenant_id = $2)`, templateID, tenantID).Scan(&ok)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("storage: check template ownership: %w", err)
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// SetTemplateTags replaces the complete set of tags on a template (bulk PUT).
|
||||
// All referenced tags must belong to the tenant. Scoped to tenant ownership of
|
||||
// the template. Delete-all + insert in one transaction (SetDocumentTypeFields
|
||||
// pattern).
|
||||
func (s *Store) SetTemplateTags(ctx context.Context, templateID, tenantID int64, tagIDs []int64) error {
|
||||
owns, err := s.templateOwned(ctx, templateID, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !owns {
|
||||
return ErrClassificationTemplateNotFound
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: begin set template tags: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM classification_template_tags WHERE template_id = $1`, templateID); err != nil {
|
||||
return fmt.Errorf("storage: clear template tags: %w", err)
|
||||
}
|
||||
for _, tagID := range tagIDs {
|
||||
var ok bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1 AND tenant_id = $2)`, tagID, tenantID).Scan(&ok); err != nil {
|
||||
return fmt.Errorf("storage: check tag ownership: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: tag_id %d", ErrTaxonomyNotFound, tagID)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO classification_template_tags (template_id, tag_id) VALUES ($1, $2)
|
||||
ON CONFLICT (template_id, tag_id) DO NOTHING
|
||||
`, templateID, tagID); err != nil {
|
||||
return fmt.Errorf("storage: insert template tag: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("storage: commit set template tags: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTemplateFieldDefaults replaces the complete set of custom-field defaults
|
||||
// on a template (bulk PUT). All referenced fields must belong to the tenant and
|
||||
// their supplied value is validated against the field type. Scoped to tenant
|
||||
// ownership of the template. Delete-all + insert in one transaction.
|
||||
func (s *Store) SetTemplateFieldDefaults(ctx context.Context, templateID, tenantID int64, defaults []TemplateFieldDefaultInput) error {
|
||||
owns, err := s.templateOwned(ctx, templateID, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !owns {
|
||||
return ErrClassificationTemplateNotFound
|
||||
}
|
||||
|
||||
// Load field definitions for type resolution / validation.
|
||||
defs, err := s.ListCustomFieldDefs(ctx, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defByID := make(map[int64]CustomFieldDef, len(defs))
|
||||
for _, d := range defs {
|
||||
defByID[d.ID] = d
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: begin set template field defaults: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM classification_template_field_defaults WHERE template_id = $1`, templateID); err != nil {
|
||||
return fmt.Errorf("storage: clear template field defaults: %w", err)
|
||||
}
|
||||
for _, in := range defaults {
|
||||
def, ok := defByID[in.FieldID]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: field_id %d", ErrCustomFieldNotFound, in.FieldID)
|
||||
}
|
||||
var (
|
||||
text *string
|
||||
number *float64
|
||||
date *time.Time
|
||||
bl *bool
|
||||
)
|
||||
switch def.FieldType {
|
||||
case "text":
|
||||
text = in.ValueText
|
||||
case "enum":
|
||||
if in.ValueText != nil && *in.ValueText != "" {
|
||||
if len(def.EnumOptions) > 0 && !containsString(def.EnumOptions, *in.ValueText) {
|
||||
return fmt.Errorf("storage: value %q not in enum options for field %q", *in.ValueText, def.Name)
|
||||
}
|
||||
}
|
||||
text = in.ValueText
|
||||
case "number", "monetary":
|
||||
number = in.ValueNumber
|
||||
case "date":
|
||||
if in.ValueDate != nil && *in.ValueDate != "" {
|
||||
parsed, err := time.Parse("2006-01-02", *in.ValueDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: invalid date %q for field %q: %w", *in.ValueDate, def.Name, err)
|
||||
}
|
||||
date = &parsed
|
||||
}
|
||||
case "boolean":
|
||||
bl = in.ValueBool
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO classification_template_field_defaults (template_id, field_id, value_text, value_number, value_date, value_bool, overwrite)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (template_id, field_id) DO UPDATE
|
||||
SET value_text = EXCLUDED.value_text, value_number = EXCLUDED.value_number,
|
||||
value_date = EXCLUDED.value_date, value_bool = EXCLUDED.value_bool, overwrite = EXCLUDED.overwrite
|
||||
`, templateID, in.FieldID, text, number, date, bl, in.Overwrite); err != nil {
|
||||
return fmt.Errorf("storage: insert template field default: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("storage: commit set template field defaults: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ApplyTemplateResult is the outcome (or dry-run preview) of applying a
|
||||
// classification template to a document. It is produced identically by the
|
||||
// preview and the commit path so there is no logic duplication between them.
|
||||
type ApplyTemplateResult struct {
|
||||
TemplateID int64 `json:"template_id"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
TagsToAdd []TaxonomyEntity `json:"tags_to_add"`
|
||||
TagsAlreadySet []TaxonomyEntity `json:"tags_already_set"`
|
||||
FieldsToSet []FieldDefaultChange `json:"fields_to_set"`
|
||||
FieldsSkipped []FieldDefaultChange `json:"fields_skipped"`
|
||||
FieldsOverwritten []FieldDefaultChange `json:"fields_overwritten"`
|
||||
RetainUntilBefore *time.Time `json:"retain_until_before,omitempty"`
|
||||
RetainUntilAfter *time.Time `json:"retain_until_after,omitempty"`
|
||||
RetainUntilBlocked bool `json:"retain_until_blocked"`
|
||||
Applied bool `json:"applied"`
|
||||
}
|
||||
|
||||
// FieldDefaultChange describes a single custom-field default's effect on a
|
||||
// document (used in the to-set / skipped / overwritten buckets).
|
||||
type FieldDefaultChange struct {
|
||||
FieldID int64 `json:"field_id"`
|
||||
Name string `json:"name"`
|
||||
OldValue string `json:"old_value,omitempty"`
|
||||
NewValue string `json:"new_value"`
|
||||
}
|
||||
|
||||
// computeRetainUntil returns createdAt + retainYears years, or nil if
|
||||
// retainYears is nil (template does not set a retention period).
|
||||
func computeRetainUntil(createdAt time.Time, retainYears *int) *time.Time {
|
||||
if retainYears == nil {
|
||||
return nil
|
||||
}
|
||||
t := createdAt.AddDate(*retainYears, 0, 0)
|
||||
return &t
|
||||
}
|
||||
|
||||
// formatTemplateDefault renders a template field default's value as a string
|
||||
// for FieldDefaultChange.NewValue.
|
||||
func formatTemplateDefault(d TemplateFieldDefault) string {
|
||||
return formatFieldValueParts(d.ValueText, d.ValueNumber, d.ValueDate, d.ValueBool)
|
||||
}
|
||||
|
||||
// formatExistingValue renders a document's current field value as a string for
|
||||
// FieldDefaultChange.OldValue.
|
||||
func formatExistingValue(v DocumentFieldValue) string {
|
||||
return formatFieldValueParts(v.ValueText, v.ValueNumber, v.ValueDate, v.ValueBool)
|
||||
}
|
||||
|
||||
func formatFieldValueParts(text *string, number *float64, date *time.Time, bl *bool) string {
|
||||
switch {
|
||||
case text != nil:
|
||||
return *text
|
||||
case number != nil:
|
||||
return strconv.FormatFloat(*number, 'f', -1, 64)
|
||||
case date != nil:
|
||||
return date.Format("2006-01-02")
|
||||
case bl != nil:
|
||||
return strconv.FormatBool(*bl)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// PreviewApplyTemplate builds the full ApplyTemplateResult WITHOUT writing
|
||||
// anything. It is both the dry-run response and the basis the commit path
|
||||
// (ApplyTemplate) reuses. Field-default classification is done per-field on the
|
||||
// template default's own `overwrite` flag: an already-set value goes to
|
||||
// FieldsOverwritten when overwrite=true, otherwise to FieldsSkipped. The commit
|
||||
// path additionally requires an explicit confirm before actually overwriting.
|
||||
func (s *Store) PreviewApplyTemplate(ctx context.Context, documentID, templateID, tenantID int64) (*ApplyTemplateResult, error) {
|
||||
// GetDocument wraps a missing/foreign-tenant row as pgx.ErrNoRows (it does
|
||||
// not map to a sentinel itself) — translate it to ErrDocumentNotFound so the
|
||||
// handler can return a clean 404.
|
||||
doc, err := s.GetDocument(ctx, documentID, tenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrDocumentNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tmpl, err := s.GetTemplate(ctx, templateID, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &ApplyTemplateResult{
|
||||
TemplateID: templateID,
|
||||
DryRun: true,
|
||||
TagsToAdd: make([]TaxonomyEntity, 0),
|
||||
TagsAlreadySet: make([]TaxonomyEntity, 0),
|
||||
FieldsToSet: make([]FieldDefaultChange, 0),
|
||||
FieldsSkipped: make([]FieldDefaultChange, 0),
|
||||
FieldsOverwritten: make([]FieldDefaultChange, 0),
|
||||
RetainUntilBefore: doc.RetainUntil,
|
||||
}
|
||||
|
||||
// --- tags ---
|
||||
existingTags, err := s.ListDocumentTags(ctx, documentID, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
haveTag := make(map[int64]bool, len(existingTags))
|
||||
for _, t := range existingTags {
|
||||
haveTag[t.ID] = true
|
||||
}
|
||||
for _, t := range tmpl.Tags {
|
||||
if haveTag[t.ID] {
|
||||
res.TagsAlreadySet = append(res.TagsAlreadySet, t)
|
||||
} else {
|
||||
res.TagsToAdd = append(res.TagsToAdd, t)
|
||||
}
|
||||
}
|
||||
|
||||
// --- custom-field defaults ---
|
||||
existingVals, err := s.ListDocumentFieldValues(ctx, documentID, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existingByID := make(map[int64]DocumentFieldValue, len(existingVals))
|
||||
for _, v := range existingVals {
|
||||
existingByID[v.FieldID] = v
|
||||
}
|
||||
for _, d := range tmpl.FieldDefaults {
|
||||
change := FieldDefaultChange{FieldID: d.FieldID, Name: d.Name, NewValue: formatTemplateDefault(d)}
|
||||
existing, has := existingByID[d.FieldID]
|
||||
existingEmpty := !has || isEmptyResolved(existing.ValueText, existing.ValueNumber, existing.ValueDate, existing.ValueBool)
|
||||
if existingEmpty {
|
||||
res.FieldsToSet = append(res.FieldsToSet, change)
|
||||
continue
|
||||
}
|
||||
change.OldValue = formatExistingValue(existing)
|
||||
if d.Overwrite {
|
||||
res.FieldsOverwritten = append(res.FieldsOverwritten, change)
|
||||
} else {
|
||||
res.FieldsSkipped = append(res.FieldsSkipped, change)
|
||||
}
|
||||
}
|
||||
|
||||
// --- retention (absolute rule: never shorten, not even with overwrite) ---
|
||||
proposed := computeRetainUntil(doc.CreatedAt, tmpl.RetainYears)
|
||||
if doc.RetainUntil != nil && (proposed == nil || proposed.Before(*doc.RetainUntil)) {
|
||||
// A template must never shorten an existing retain_until.
|
||||
res.RetainUntilBlocked = true
|
||||
} else if proposed != nil {
|
||||
res.RetainUntilAfter = proposed
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ApplyTemplate applies a classification template to a document. It first
|
||||
// builds the plan via PreviewApplyTemplate, then commits it: attaches the
|
||||
// missing tags, merges the field defaults into the document's existing values
|
||||
// (never a full replace), and extends retain_until — but ONLY when it is not
|
||||
// blocked (retention is never shortened, even with overwrite=true). The
|
||||
// per-field overwrite of an already-set value additionally requires the global
|
||||
// overwrite/confirm flag; otherwise those fields are demoted to FieldsSkipped.
|
||||
// The returned result reflects what was actually done (Applied=true).
|
||||
func (s *Store) ApplyTemplate(ctx context.Context, documentID, templateID, tenantID int64, overwrite bool) (*ApplyTemplateResult, error) {
|
||||
res, err := s.PreviewApplyTemplate(ctx, documentID, templateID, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.DryRun = false
|
||||
|
||||
// Without an explicit confirm, overwriting an already-set field is not
|
||||
// performed — demote those to skipped so the caller sees they were kept.
|
||||
if !overwrite && len(res.FieldsOverwritten) > 0 {
|
||||
res.FieldsSkipped = append(res.FieldsSkipped, res.FieldsOverwritten...)
|
||||
res.FieldsOverwritten = res.FieldsOverwritten[:0]
|
||||
}
|
||||
|
||||
// --- attach missing tags (AttachTag runs RecomputeVisibility itself) ---
|
||||
for _, t := range res.TagsToAdd {
|
||||
if err := s.AttachTag(ctx, documentID, t.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// --- field defaults: merge with existing values, then set once ---
|
||||
// The set of field_ids the template actually writes (to-set + confirmed
|
||||
// overwrites). SetDocumentFieldValues is full-replace, so we start from the
|
||||
// document's current values and overlay only the template's writes.
|
||||
writeFields := make(map[int64]bool)
|
||||
for _, c := range res.FieldsToSet {
|
||||
writeFields[c.FieldID] = true
|
||||
}
|
||||
for _, c := range res.FieldsOverwritten {
|
||||
writeFields[c.FieldID] = true
|
||||
}
|
||||
if len(writeFields) > 0 {
|
||||
if err := s.applyTemplateFieldValues(ctx, documentID, templateID, tenantID, writeFields); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// --- retention: extend only, never shorten ---
|
||||
if !res.RetainUntilBlocked && res.RetainUntilAfter != nil {
|
||||
if _, err := s.db.Exec(ctx, `UPDATE documents SET retain_until = $1, updated_at = now() WHERE id = $2 AND tenant_id = $3`,
|
||||
*res.RetainUntilAfter, documentID, tenantID); err != nil {
|
||||
return nil, fmt.Errorf("storage: apply template retain_until: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- title: template-own title_template, else tenant default, else keep.
|
||||
// Runs AFTER tags/fields so tag-based patterns see the freshly attached
|
||||
// tags. Only touches non-manually-renamed documents; keeps
|
||||
// title_manually_set = false. Identical for the manual endpoint and the
|
||||
// workflow trigger (both call ApplyTemplate). ---
|
||||
tmpl, err := s.GetTemplate(ctx, templateID, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.applyTemplateTitle(ctx, documentID, tenantID, tmpl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res.Applied = true
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// applyTemplateFieldValues merges the template's writeFields defaults into the
|
||||
// document's current custom-field values and persists the union via
|
||||
// SetDocumentFieldValues (which is full-replace, hence the merge).
|
||||
func (s *Store) applyTemplateFieldValues(ctx context.Context, documentID, templateID, tenantID int64, writeFields map[int64]bool) error {
|
||||
existing, err := s.ListDocumentFieldValues(ctx, documentID, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defaults, err := s.listTemplateFieldDefaults(ctx, templateID, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defByID := make(map[int64]TemplateFieldDefault, len(defaults))
|
||||
for _, d := range defaults {
|
||||
defByID[d.FieldID] = d
|
||||
}
|
||||
|
||||
inputs := make([]DocumentFieldValueInput, 0, len(existing)+len(writeFields))
|
||||
seen := make(map[int64]bool)
|
||||
// Keep existing values, overlaying template writes where applicable.
|
||||
for _, v := range existing {
|
||||
seen[v.FieldID] = true
|
||||
if writeFields[v.FieldID] {
|
||||
if d, ok := defByID[v.FieldID]; ok {
|
||||
inputs = append(inputs, templateDefaultToInput(d))
|
||||
continue
|
||||
}
|
||||
}
|
||||
inputs = append(inputs, existingValueToInput(v))
|
||||
}
|
||||
// Template writes for fields the document did not have yet.
|
||||
for fid := range writeFields {
|
||||
if seen[fid] {
|
||||
continue
|
||||
}
|
||||
if d, ok := defByID[fid]; ok {
|
||||
inputs = append(inputs, templateDefaultToInput(d))
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.SetDocumentFieldValues(ctx, documentID, tenantID, inputs); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func templateDefaultToInput(d TemplateFieldDefault) DocumentFieldValueInput {
|
||||
in := DocumentFieldValueInput{
|
||||
FieldID: d.FieldID,
|
||||
ValueText: d.ValueText,
|
||||
ValueNumber: d.ValueNumber,
|
||||
ValueBool: d.ValueBool,
|
||||
}
|
||||
if d.ValueDate != nil {
|
||||
s := d.ValueDate.Format("2006-01-02")
|
||||
in.ValueDate = &s
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func existingValueToInput(v DocumentFieldValue) DocumentFieldValueInput {
|
||||
in := DocumentFieldValueInput{
|
||||
FieldID: v.FieldID,
|
||||
ValueText: v.ValueText,
|
||||
ValueNumber: v.ValueNumber,
|
||||
ValueBool: v.ValueBool,
|
||||
}
|
||||
if v.ValueDate != nil {
|
||||
s := v.ValueDate.Format("2006-01-02")
|
||||
in.ValueDate = &s
|
||||
}
|
||||
return in
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// titleTemplateData is the data model exposed to a classification template's
|
||||
// (or the tenant-wide default's) title Go text/template. Fields are kept simple
|
||||
// (plain strings / times) so patterns stay readable, e.g.
|
||||
//
|
||||
// {{.Correspondent}} {{.DocumentType}} {{dateFormat "02.01.2006" .Belegdatum}}
|
||||
//
|
||||
// Belegdatum and UploadDate are passed as time.Time (zero value when unknown);
|
||||
// use the dateFormat template func to render them, which yields "" for a zero
|
||||
// time instead of Go's "0001-01-01..." default.
|
||||
type titleTemplateData struct {
|
||||
Correspondent string
|
||||
DocumentType string
|
||||
Belegdatum time.Time
|
||||
UploadDate time.Time
|
||||
Tags string
|
||||
OCRTitle string
|
||||
}
|
||||
|
||||
// titleTemplateFuncs provides the custom template functions available inside a
|
||||
// title template. dateFormat takes a Go reference layout ("02.01.2006") and a
|
||||
// time.Time, returning "" for a zero time so an unknown Belegdatum does not
|
||||
// leak a placeholder date into the title.
|
||||
var titleTemplateFuncs = template.FuncMap{
|
||||
"dateFormat": func(layout string, t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(layout)
|
||||
},
|
||||
}
|
||||
|
||||
// ValidateTitleTemplate parses (but does not execute) a title template pattern
|
||||
// so the API layer can reject a syntactically invalid pattern up front. An
|
||||
// empty pattern is valid (means "no template title"). Exported for the
|
||||
// settings / template CRUD handlers.
|
||||
func ValidateTitleTemplate(pattern string) error {
|
||||
if strings.TrimSpace(pattern) == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := template.New("title").Option("missingkey=zero").Funcs(titleTemplateFuncs).Parse(pattern)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid title template: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderTitleTemplate parses and executes a title template against data. The
|
||||
// result is whitespace-trimmed. missingkey=zero guards against crashes when a
|
||||
// pattern references a field that does not exist. A parse/execute error or an
|
||||
// empty result is signalled to the caller so it can fall back (never an empty
|
||||
// title).
|
||||
func renderTitleTemplate(pattern string, data titleTemplateData) (string, error) {
|
||||
tmpl, err := template.New("title").Option("missingkey=zero").Funcs(titleTemplateFuncs).Parse(pattern)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("storage: parse title template: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", fmt.Errorf("storage: execute title template: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(buf.String()), nil
|
||||
}
|
||||
|
||||
// tenantDefaultTitleTemplate reads the tenant-wide fallback title template
|
||||
// straight from the tenants table (same DB pool). Returns "" when unset
|
||||
// (NULL) so callers can treat "no default" uniformly.
|
||||
func (s *Store) tenantDefaultTitleTemplate(ctx context.Context, tenantID int64) (string, error) {
|
||||
var v *string
|
||||
err := s.db.QueryRow(ctx, `SELECT default_title_template FROM tenants WHERE id = $1`, tenantID).Scan(&v)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("storage: read tenant default_title_template: %w", err)
|
||||
}
|
||||
if v == nil {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(*v), nil
|
||||
}
|
||||
|
||||
// taxonomyNameByID resolves a taxonomy entity's display name (tenant-scoped).
|
||||
// table must be a fixed internal literal ("correspondents" / "document_types"),
|
||||
// never user input. Returns "" (not an error) when the row does not exist so a
|
||||
// dangling reference cannot break title generation.
|
||||
func (s *Store) taxonomyNameByID(ctx context.Context, table string, id, tenantID int64) (string, error) {
|
||||
var name string
|
||||
err := s.db.QueryRow(ctx, fmt.Sprintf(`SELECT name FROM %s WHERE id = $1 AND tenant_id = $2`, table), id, tenantID).Scan(&name)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("storage: lookup %s name: %w", table, err)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// applyTemplateTitle derives and persists a document title from a classification
|
||||
// template's title_template (or, if that is empty, the tenant-wide
|
||||
// default_title_template). Rules (see feature spec):
|
||||
//
|
||||
// - Never touches a manually renamed document (title_manually_set = true).
|
||||
// - Chooses the template's own pattern first, then the tenant default; if both
|
||||
// are empty, does nothing (existing title kept).
|
||||
// - On an empty or errored render result, falls back to keeping the existing
|
||||
// title — never sets an empty string.
|
||||
// - Persists via UpdateDocumentTitleAuto so title_manually_set stays false
|
||||
// (a later correction + re-apply must still work).
|
||||
//
|
||||
// Called at the end of ApplyTemplate, so it runs identically for the manual
|
||||
// endpoint and the workflow trigger (both funnel through ApplyTemplate).
|
||||
func (s *Store) applyTemplateTitle(ctx context.Context, documentID, tenantID int64, tmpl *ClassificationTemplate) error {
|
||||
doc, err := s.GetDocument(ctx, documentID, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if doc.TitleManuallySet {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve the effective pattern: template-own first, then tenant default.
|
||||
pattern := ""
|
||||
if tmpl.TitleTemplate != nil {
|
||||
pattern = strings.TrimSpace(*tmpl.TitleTemplate)
|
||||
}
|
||||
if pattern == "" {
|
||||
def, err := s.tenantDefaultTitleTemplate(ctx, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pattern = def
|
||||
}
|
||||
if pattern == "" {
|
||||
return nil // no template title configured at either level
|
||||
}
|
||||
|
||||
// Build the render data. Name lookups are best-effort (missing rows -> "").
|
||||
data := titleTemplateData{
|
||||
OCRTitle: doc.Title,
|
||||
UploadDate: doc.CreatedAt,
|
||||
}
|
||||
if doc.DocumentDate != nil {
|
||||
data.Belegdatum = *doc.DocumentDate
|
||||
}
|
||||
if doc.CorrespondentID != nil {
|
||||
name, err := s.taxonomyNameByID(ctx, "correspondents", *doc.CorrespondentID, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data.Correspondent = name
|
||||
}
|
||||
// Document type: prefer the template's target type (what is being applied),
|
||||
// falling back to the document's current type.
|
||||
docTypeID := tmpl.DocTypeID
|
||||
if docTypeID == nil {
|
||||
docTypeID = doc.DocTypeID
|
||||
}
|
||||
if docTypeID != nil {
|
||||
name, err := s.taxonomyNameByID(ctx, "document_types", *docTypeID, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data.DocumentType = name
|
||||
}
|
||||
tags, err := s.ListDocumentTags(ctx, documentID, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := make([]string, 0, len(tags))
|
||||
for _, t := range tags {
|
||||
names = append(names, t.Name)
|
||||
}
|
||||
data.Tags = strings.Join(names, ", ")
|
||||
|
||||
rendered, err := renderTitleTemplate(pattern, data)
|
||||
if err != nil || rendered == "" {
|
||||
// Fallback: keep the existing (OCR-derived) title, never blank it.
|
||||
return nil //nolint:nilerr // intentional: a bad template must not fail the apply
|
||||
}
|
||||
if rendered == doc.Title {
|
||||
return nil
|
||||
}
|
||||
return s.UpdateDocumentTitleAuto(ctx, documentID, tenantID, rendered)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Read-only aggregate queries backing the GoBD "Verfahrensdokumentation"
|
||||
// draft generator (internal/api/compliance_handlers.go). No schema of its own —
|
||||
// nothing here writes, so there is no initSchema and no migration file.
|
||||
//
|
||||
// Every query is strictly scoped to one tenant_id (application-side
|
||||
// multi-tenancy, no Postgres RLS): the generated document is a per-tenant
|
||||
// artefact and must never mix data of two tenants.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ComplianceStats are the aggregate key figures embedded in the generated
|
||||
// Verfahrensdokumentation draft. All counters refer to exactly one tenant.
|
||||
type ComplianceStats struct {
|
||||
Documents int64 // active documents (deleted_at IS NULL)
|
||||
DocumentsInTrash int64 // soft-deleted, not yet finally deleted
|
||||
DocumentsWithRetain int64 // active documents carrying a retain_until date
|
||||
PermissionGroups int64
|
||||
DocTypeGrants int64
|
||||
TagGrants int64
|
||||
DocumentGrants int64
|
||||
DeleteRequestsByStat map[string]int64 // status -> count
|
||||
}
|
||||
|
||||
// ComplianceStatsForTenant collects the aggregate figures for one tenant.
|
||||
func (s *Store) ComplianceStatsForTenant(ctx context.Context, tenantID int64) (*ComplianceStats, error) {
|
||||
st := &ComplianceStats{DeleteRequestsByStat: map[string]int64{}}
|
||||
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE deleted_at IS NULL),
|
||||
COUNT(*) FILTER (WHERE deleted_at IS NOT NULL),
|
||||
COUNT(*) FILTER (WHERE deleted_at IS NULL AND retain_until IS NOT NULL)
|
||||
FROM documents WHERE tenant_id = $1
|
||||
`, tenantID).Scan(&st.Documents, &st.DocumentsInTrash, &st.DocumentsWithRetain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: compliance document stats: %w", err)
|
||||
}
|
||||
|
||||
err = s.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM permission_groups WHERE tenant_id = $1),
|
||||
(SELECT COUNT(*) FROM document_type_grants WHERE tenant_id = $1),
|
||||
(SELECT COUNT(*) FROM tag_grants WHERE tenant_id = $1),
|
||||
(SELECT COUNT(*) FROM document_grants WHERE tenant_id = $1)
|
||||
`, tenantID).Scan(&st.PermissionGroups, &st.DocTypeGrants, &st.TagGrants, &st.DocumentGrants)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: compliance grant stats: %w", err)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT status, COUNT(*) FROM document_delete_requests
|
||||
WHERE tenant_id = $1 GROUP BY status ORDER BY status
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: compliance delete-request stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var n int64
|
||||
if err := rows.Scan(&status, &n); err != nil {
|
||||
return nil, fmt.Errorf("storage: scan compliance delete-request stats: %w", err)
|
||||
}
|
||||
st.DeleteRequestsByStat[status] = n
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("storage: compliance delete-request stats: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// ErrCustomFieldNotFound is returned when a custom-field definition lookup,
|
||||
// update or delete does not match any row owned by the caller's tenant.
|
||||
var ErrCustomFieldNotFound = errors.New("storage: custom field not found or not owned by tenant")
|
||||
|
||||
// ErrDuplicateCustomFieldName is returned when a tenant already has a custom
|
||||
// field with the same name (UNIQUE(tenant_id, name)).
|
||||
var ErrDuplicateCustomFieldName = errors.New("storage: custom field with this name already exists for tenant")
|
||||
|
||||
// ErrCustomFieldInUse is returned by DeleteCustomFieldDef when values still
|
||||
// reference the field — the caller translates this into an HTTP 409.
|
||||
var ErrCustomFieldInUse = errors.New("storage: custom field still has values and cannot be deleted")
|
||||
|
||||
// ErrRequiredFieldMissing is returned by SetDocumentFieldValues when a field
|
||||
// marked required for the document's document_type has no value supplied.
|
||||
var ErrRequiredFieldMissing = errors.New("storage: required custom field missing a value")
|
||||
|
||||
// validFieldTypes mirrors the CHECK constraint on custom_field_defs.field_type.
|
||||
var validFieldTypes = map[string]bool{
|
||||
"text": true, "number": true, "date": true,
|
||||
"boolean": true, "enum": true, "monetary": true,
|
||||
}
|
||||
|
||||
// CustomFieldDef is a tenant-scoped custom-field definition.
|
||||
type CustomFieldDef struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
FieldType string `json:"field_type"`
|
||||
EnumOptions []string `json:"enum_options,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CustomFieldDefRequest holds create parameters for a custom-field definition.
|
||||
type CustomFieldDefRequest struct {
|
||||
Name string
|
||||
Label string
|
||||
FieldType string
|
||||
EnumOptions []string
|
||||
Currency string
|
||||
}
|
||||
|
||||
// DocumentTypeField is a custom field assigned to a document type, carrying
|
||||
// the assignment metadata (required/visible/sort_order) plus the resolved
|
||||
// field definition.
|
||||
type DocumentTypeField struct {
|
||||
FieldID int64 `json:"field_id"`
|
||||
Required bool `json:"required"`
|
||||
Visible bool `json:"visible"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Field CustomFieldDef `json:"field"`
|
||||
}
|
||||
|
||||
// DocumentTypeFieldAssignment is one entry of a bulk PUT replacing a document
|
||||
// type's field assignments.
|
||||
type DocumentTypeFieldAssignment struct {
|
||||
FieldID int64
|
||||
Required bool
|
||||
Visible bool
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
// DocumentFieldValue is a single custom-field value on a document, with the
|
||||
// value carried in the type-appropriate column.
|
||||
type DocumentFieldValue struct {
|
||||
FieldID int64 `json:"field_id"`
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
FieldType string `json:"field_type"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
ValueText *string `json:"value_text,omitempty"`
|
||||
ValueNumber *float64 `json:"value_number,omitempty"`
|
||||
ValueDate *time.Time `json:"value_date,omitempty"`
|
||||
ValueBool *bool `json:"value_bool,omitempty"`
|
||||
}
|
||||
|
||||
// DocumentFieldValueInput is one supplied value in a batch PUT. Exactly one of
|
||||
// the value pointers is expected to be populated (matching the field's type).
|
||||
type DocumentFieldValueInput struct {
|
||||
FieldID int64 `json:"field_id"`
|
||||
ValueText *string `json:"value_text,omitempty"`
|
||||
ValueNumber *float64 `json:"value_number,omitempty"`
|
||||
ValueDate *string `json:"value_date,omitempty"` // ISO date "2006-01-02"
|
||||
ValueBool *bool `json:"value_bool,omitempty"`
|
||||
}
|
||||
|
||||
// initCustomFieldsSchema creates the custom_field_defs / document_type_fields /
|
||||
// document_field_values tables. Idempotent, called from (*Store).initSchema.
|
||||
// Documented (not executed) in migrations/006_custom_fields.sql.
|
||||
func (s *Store) initCustomFieldsSchema(ctx context.Context) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS custom_field_defs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
field_type TEXT NOT NULL CHECK (field_type IN ('text','number','date','boolean','enum','monetary')),
|
||||
enum_options JSONB,
|
||||
currency TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_type_fields (
|
||||
doc_type_id BIGINT NOT NULL REFERENCES document_types(id) ON DELETE CASCADE,
|
||||
field_id BIGINT NOT NULL REFERENCES custom_field_defs(id) ON DELETE CASCADE,
|
||||
required BOOLEAN NOT NULL DEFAULT false,
|
||||
visible BOOLEAN NOT NULL DEFAULT true,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (doc_type_id, field_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_field_values (
|
||||
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
field_id BIGINT NOT NULL REFERENCES custom_field_defs(id) ON DELETE CASCADE,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
value_text TEXT,
|
||||
value_number NUMERIC,
|
||||
value_date DATE,
|
||||
value_bool BOOLEAN,
|
||||
PRIMARY KEY (document_id, field_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dfv_tenant_field ON document_field_values(tenant_id, field_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dfv_field_text ON document_field_values(field_id, value_text);
|
||||
CREATE INDEX IF NOT EXISTS idx_dfv_field_number ON document_field_values(field_id, value_number);
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: create custom fields tables: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanCustomFieldDef(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (*CustomFieldDef, error) {
|
||||
var d CustomFieldDef
|
||||
var enumRaw []byte
|
||||
var currency *string
|
||||
if err := row.Scan(&d.ID, &d.TenantID, &d.Name, &d.Label, &d.FieldType, &enumRaw, ¤cy, &d.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(enumRaw) > 0 {
|
||||
if err := json.Unmarshal(enumRaw, &d.EnumOptions); err != nil {
|
||||
return nil, fmt.Errorf("storage: unmarshal enum_options: %w", err)
|
||||
}
|
||||
}
|
||||
if currency != nil {
|
||||
d.Currency = *currency
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// ListCustomFieldDefs returns all custom-field definitions for a tenant.
|
||||
func (s *Store) ListCustomFieldDefs(ctx context.Context, tenantID int64) ([]CustomFieldDef, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, tenant_id, name, label, field_type, enum_options, currency, created_at
|
||||
FROM custom_field_defs WHERE tenant_id = $1 ORDER BY name ASC
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: list custom fields: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]CustomFieldDef, 0)
|
||||
for rows.Next() {
|
||||
d, err := scanCustomFieldDef(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: scan custom field: %w", err)
|
||||
}
|
||||
out = append(out, *d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetCustomFieldDef returns one custom-field definition, scoped to tenant.
|
||||
func (s *Store) GetCustomFieldDef(ctx context.Context, id, tenantID int64) (*CustomFieldDef, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, name, label, field_type, enum_options, currency, created_at
|
||||
FROM custom_field_defs WHERE id = $1 AND tenant_id = $2
|
||||
`, id, tenantID)
|
||||
d, err := scanCustomFieldDef(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrCustomFieldNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("storage: get custom field: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// CreateCustomFieldDef inserts a new custom-field definition.
|
||||
func (s *Store) CreateCustomFieldDef(ctx context.Context, tenantID int64, req CustomFieldDefRequest) (*CustomFieldDef, error) {
|
||||
if !validFieldTypes[req.FieldType] {
|
||||
return nil, fmt.Errorf("storage: invalid field_type %q", req.FieldType)
|
||||
}
|
||||
var enumRaw []byte
|
||||
if len(req.EnumOptions) > 0 {
|
||||
b, err := json.Marshal(req.EnumOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: marshal enum_options: %w", err)
|
||||
}
|
||||
enumRaw = b
|
||||
}
|
||||
row := s.db.QueryRow(ctx, `
|
||||
INSERT INTO custom_field_defs (tenant_id, name, label, field_type, enum_options, currency)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, tenant_id, name, label, field_type, enum_options, currency, created_at
|
||||
`, tenantID, req.Name, req.Label, req.FieldType, enumRaw, nullIfEmpty(req.Currency))
|
||||
d, err := scanCustomFieldDef(row)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return nil, ErrDuplicateCustomFieldName
|
||||
}
|
||||
return nil, fmt.Errorf("storage: create custom field: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// UpdateCustomFieldDef updates the label, enum_options and currency of a
|
||||
// custom-field definition. Name and field_type are immutable (they anchor
|
||||
// stored values), matching the API contract. Scoped to tenant ownership.
|
||||
func (s *Store) UpdateCustomFieldDef(ctx context.Context, id, tenantID int64, label string, enumOptions []string, currency string) (*CustomFieldDef, error) {
|
||||
var enumRaw []byte
|
||||
if len(enumOptions) > 0 {
|
||||
b, err := json.Marshal(enumOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: marshal enum_options: %w", err)
|
||||
}
|
||||
enumRaw = b
|
||||
}
|
||||
row := s.db.QueryRow(ctx, `
|
||||
UPDATE custom_field_defs SET label = $1, enum_options = $2, currency = $3
|
||||
WHERE id = $4 AND tenant_id = $5
|
||||
RETURNING id, tenant_id, name, label, field_type, enum_options, currency, created_at
|
||||
`, label, enumRaw, nullIfEmpty(currency), id, tenantID)
|
||||
d, err := scanCustomFieldDef(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrCustomFieldNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("storage: update custom field: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// DeleteCustomFieldDef deletes a custom-field definition, but only if no
|
||||
// document_field_values reference it. Returns ErrCustomFieldInUse otherwise.
|
||||
// Scoped to tenant ownership.
|
||||
func (s *Store) DeleteCustomFieldDef(ctx context.Context, id, tenantID int64) error {
|
||||
// Ownership check first — distinguishes 404 from 409.
|
||||
if _, err := s.GetCustomFieldDef(ctx, id, tenantID); err != nil {
|
||||
return err
|
||||
}
|
||||
var inUse bool
|
||||
if err := s.db.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM document_field_values WHERE field_id = $1 AND tenant_id = $2)
|
||||
`, id, tenantID).Scan(&inUse); err != nil {
|
||||
return fmt.Errorf("storage: check custom field usage: %w", err)
|
||||
}
|
||||
if inUse {
|
||||
return ErrCustomFieldInUse
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `DELETE FROM custom_field_defs WHERE id = $1 AND tenant_id = $2`, id, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: delete custom field: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrCustomFieldNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ownsDocumentType returns true if the document type is owned by the tenant.
|
||||
func (s *Store) ownsDocumentType(ctx context.Context, docTypeID, tenantID int64) (bool, error) {
|
||||
var ok bool
|
||||
err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM document_types WHERE id = $1 AND tenant_id = $2)`, docTypeID, tenantID).Scan(&ok)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("storage: check document type ownership: %w", err)
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// ListDocumentTypeFields returns the custom fields assigned to a document type
|
||||
// (with required/visible/sort_order), joined to their definitions. Scoped to
|
||||
// tenant ownership of the document type.
|
||||
func (s *Store) ListDocumentTypeFields(ctx context.Context, docTypeID, tenantID int64) ([]DocumentTypeField, error) {
|
||||
owns, err := s.ownsDocumentType(ctx, docTypeID, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !owns {
|
||||
return nil, ErrTaxonomyNotFound
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT dtf.field_id, dtf.required, dtf.visible, dtf.sort_order,
|
||||
f.id, f.tenant_id, f.name, f.label, f.field_type, f.enum_options, f.currency, f.created_at
|
||||
FROM document_type_fields dtf
|
||||
JOIN custom_field_defs f ON f.id = dtf.field_id
|
||||
WHERE dtf.doc_type_id = $1 AND f.tenant_id = $2
|
||||
ORDER BY dtf.sort_order ASC, f.name ASC
|
||||
`, docTypeID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: list document type fields: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]DocumentTypeField, 0)
|
||||
for rows.Next() {
|
||||
var a DocumentTypeField
|
||||
var f CustomFieldDef
|
||||
var enumRaw []byte
|
||||
var currency *string
|
||||
if err := rows.Scan(&a.FieldID, &a.Required, &a.Visible, &a.SortOrder,
|
||||
&f.ID, &f.TenantID, &f.Name, &f.Label, &f.FieldType, &enumRaw, ¤cy, &f.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("storage: scan document type field: %w", err)
|
||||
}
|
||||
if len(enumRaw) > 0 {
|
||||
if err := json.Unmarshal(enumRaw, &f.EnumOptions); err != nil {
|
||||
return nil, fmt.Errorf("storage: unmarshal enum_options: %w", err)
|
||||
}
|
||||
}
|
||||
if currency != nil {
|
||||
f.Currency = *currency
|
||||
}
|
||||
a.Field = f
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetDocumentTypeFields replaces the complete set of field assignments for a
|
||||
// document type (bulk PUT). All referenced fields must belong to the tenant.
|
||||
// Scoped to tenant ownership of the document type.
|
||||
func (s *Store) SetDocumentTypeFields(ctx context.Context, docTypeID, tenantID int64, assignments []DocumentTypeFieldAssignment) error {
|
||||
owns, err := s.ownsDocumentType(ctx, docTypeID, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !owns {
|
||||
return ErrTaxonomyNotFound
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: begin set document type fields: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM document_type_fields WHERE doc_type_id = $1`, docTypeID); err != nil {
|
||||
return fmt.Errorf("storage: clear document type fields: %w", err)
|
||||
}
|
||||
for _, a := range assignments {
|
||||
// Verify field ownership by tenant before linking.
|
||||
var ok bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM custom_field_defs WHERE id = $1 AND tenant_id = $2)`, a.FieldID, tenantID).Scan(&ok); err != nil {
|
||||
return fmt.Errorf("storage: check field ownership: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: field_id %d", ErrCustomFieldNotFound, a.FieldID)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO document_type_fields (doc_type_id, field_id, required, visible, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (doc_type_id, field_id) DO UPDATE
|
||||
SET required = EXCLUDED.required, visible = EXCLUDED.visible, sort_order = EXCLUDED.sort_order
|
||||
`, docTypeID, a.FieldID, a.Required, a.Visible, a.SortOrder); err != nil {
|
||||
return fmt.Errorf("storage: insert document type field: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("storage: commit set document type fields: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListDocumentFieldValues returns the custom-field values stored on a document
|
||||
// (joined to their definitions), scoped to tenant. Ownership of the document
|
||||
// must be verified by the caller.
|
||||
func (s *Store) ListDocumentFieldValues(ctx context.Context, documentID, tenantID int64) ([]DocumentFieldValue, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT v.field_id, f.name, f.label, f.field_type, f.currency,
|
||||
v.value_text, v.value_number, v.value_date, v.value_bool
|
||||
FROM document_field_values v
|
||||
JOIN custom_field_defs f ON f.id = v.field_id
|
||||
WHERE v.document_id = $1 AND v.tenant_id = $2
|
||||
ORDER BY f.name ASC
|
||||
`, documentID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: list document field values: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]DocumentFieldValue, 0)
|
||||
for rows.Next() {
|
||||
var v DocumentFieldValue
|
||||
var currency *string
|
||||
if err := rows.Scan(&v.FieldID, &v.Name, &v.Label, &v.FieldType, ¤cy,
|
||||
&v.ValueText, &v.ValueNumber, &v.ValueDate, &v.ValueBool); err != nil {
|
||||
return nil, fmt.Errorf("storage: scan document field value: %w", err)
|
||||
}
|
||||
if currency != nil {
|
||||
v.Currency = *currency
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetDocumentFieldValues sets (upserts) a batch of custom-field values on a
|
||||
// document and deletes any values not present in the batch. It validates each
|
||||
// field against its type and enforces required fields for the document's
|
||||
// document_type server-side. Returns the names of fields whose value changed
|
||||
// (for audit logging). Scoped to tenant. Ownership of the document must be
|
||||
// verified by the caller.
|
||||
func (s *Store) SetDocumentFieldValues(ctx context.Context, documentID, tenantID int64, inputs []DocumentFieldValueInput) ([]string, error) {
|
||||
// Load the tenant's field definitions for type resolution.
|
||||
defs, err := s.ListCustomFieldDefs(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defByID := make(map[int64]CustomFieldDef, len(defs))
|
||||
for _, d := range defs {
|
||||
defByID[d.ID] = d
|
||||
}
|
||||
|
||||
// Resolve the document's document_type_id to know which fields are required.
|
||||
var docTypeID *int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT doc_type_id FROM documents WHERE id = $1 AND tenant_id = $2`, documentID, tenantID).Scan(&docTypeID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("storage: document not found or not owned by tenant")
|
||||
}
|
||||
return nil, fmt.Errorf("storage: resolve document doc_type: %w", err)
|
||||
}
|
||||
|
||||
// Build the set of supplied non-empty field values keyed by field_id.
|
||||
type resolved struct {
|
||||
text *string
|
||||
number *float64
|
||||
date *time.Time
|
||||
bl *bool
|
||||
}
|
||||
supplied := make(map[int64]resolved, len(inputs))
|
||||
for _, in := range inputs {
|
||||
def, ok := defByID[in.FieldID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: field_id %d", ErrCustomFieldNotFound, in.FieldID)
|
||||
}
|
||||
var r resolved
|
||||
switch def.FieldType {
|
||||
case "text":
|
||||
r.text = in.ValueText
|
||||
case "enum":
|
||||
if in.ValueText != nil && *in.ValueText != "" {
|
||||
if len(def.EnumOptions) > 0 && !containsString(def.EnumOptions, *in.ValueText) {
|
||||
return nil, fmt.Errorf("storage: value %q not in enum options for field %q", *in.ValueText, def.Name)
|
||||
}
|
||||
}
|
||||
r.text = in.ValueText
|
||||
case "number", "monetary":
|
||||
r.number = in.ValueNumber
|
||||
case "date":
|
||||
if in.ValueDate != nil && *in.ValueDate != "" {
|
||||
t, err := time.Parse("2006-01-02", *in.ValueDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: invalid date %q for field %q: %w", *in.ValueDate, def.Name, err)
|
||||
}
|
||||
r.date = &t
|
||||
}
|
||||
case "boolean":
|
||||
r.bl = in.ValueBool
|
||||
}
|
||||
supplied[in.FieldID] = r
|
||||
}
|
||||
|
||||
// Required-field validation against the document's type assignments.
|
||||
if docTypeID != nil {
|
||||
reqRows, err := s.db.Query(ctx, `
|
||||
SELECT dtf.field_id FROM document_type_fields dtf
|
||||
JOIN custom_field_defs f ON f.id = dtf.field_id
|
||||
WHERE dtf.doc_type_id = $1 AND f.tenant_id = $2 AND dtf.required = true
|
||||
`, *docTypeID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: load required fields: %w", err)
|
||||
}
|
||||
var requiredIDs []int64
|
||||
for reqRows.Next() {
|
||||
var fid int64
|
||||
if err := reqRows.Scan(&fid); err != nil {
|
||||
reqRows.Close()
|
||||
return nil, fmt.Errorf("storage: scan required field: %w", err)
|
||||
}
|
||||
requiredIDs = append(requiredIDs, fid)
|
||||
}
|
||||
reqRows.Close()
|
||||
if err := reqRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, fid := range requiredIDs {
|
||||
r, ok := supplied[fid]
|
||||
if !ok || isEmptyResolved(r.text, r.number, r.date, r.bl) {
|
||||
def := defByID[fid]
|
||||
return nil, fmt.Errorf("%w: %s", ErrRequiredFieldMissing, def.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine current values to compute the changed-field set for audit.
|
||||
existing, err := s.ListDocumentFieldValues(ctx, documentID, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existingByID := make(map[int64]DocumentFieldValue, len(existing))
|
||||
for _, e := range existing {
|
||||
existingByID[e.FieldID] = e
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: begin set document field values: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var changed []string
|
||||
keep := make(map[int64]bool, len(supplied))
|
||||
for fid, r := range supplied {
|
||||
def := defByID[fid]
|
||||
// Empty value => treat as deletion (handled by the not-kept sweep).
|
||||
if isEmptyResolved(r.text, r.number, r.date, r.bl) {
|
||||
continue
|
||||
}
|
||||
keep[fid] = true
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO document_field_values (document_id, field_id, tenant_id, value_text, value_number, value_date, value_bool)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (document_id, field_id) DO UPDATE
|
||||
SET value_text = EXCLUDED.value_text, value_number = EXCLUDED.value_number,
|
||||
value_date = EXCLUDED.value_date, value_bool = EXCLUDED.value_bool
|
||||
`, documentID, fid, tenantID, r.text, r.number, r.date, r.bl); err != nil {
|
||||
return nil, fmt.Errorf("storage: upsert document field value: %w", err)
|
||||
}
|
||||
if changedValue(existingByID[fid], r.text, r.number, r.date, r.bl) {
|
||||
changed = append(changed, def.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete values that were present but are no longer supplied (or were
|
||||
// supplied empty). Only within this tenant/document.
|
||||
for fid, e := range existingByID {
|
||||
if keep[fid] {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM document_field_values WHERE document_id = $1 AND field_id = $2 AND tenant_id = $3`, documentID, fid, tenantID); err != nil {
|
||||
return nil, fmt.Errorf("storage: delete document field value: %w", err)
|
||||
}
|
||||
changed = append(changed, e.Name)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("storage: commit set document field values: %w", err)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func containsString(list []string, s string) bool {
|
||||
for _, v := range list {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isEmptyResolved(text *string, number *float64, date *time.Time, bl *bool) bool {
|
||||
if text != nil && *text != "" {
|
||||
return false
|
||||
}
|
||||
if number != nil {
|
||||
return false
|
||||
}
|
||||
if date != nil {
|
||||
return false
|
||||
}
|
||||
if bl != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func changedValue(prev DocumentFieldValue, text *string, number *float64, date *time.Time, bl *bool) bool {
|
||||
if !ptrEqStr(prev.ValueText, text) {
|
||||
return true
|
||||
}
|
||||
if !ptrEqFloat(prev.ValueNumber, number) {
|
||||
return true
|
||||
}
|
||||
if !ptrEqDate(prev.ValueDate, date) {
|
||||
return true
|
||||
}
|
||||
if !ptrEqBool(prev.ValueBool, bl) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ptrEqStr(a, b *string) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func ptrEqFloat(a, b *float64) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func ptrEqBool(a, b *bool) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func ptrEqDate(a, b *time.Time) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day()
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Dashboard aggregation store. MVP: a single struct of live COUNT/GROUP BY
|
||||
// queries per tenant, no caching layer and no materialized views. All queries
|
||||
// are tenant-scoped (WHERE tenant_id = $1) exactly like the rest of the store.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DashboardStats is the aggregated key-figure snapshot for one tenant, served
|
||||
// by GET /api/dashboard.
|
||||
type DashboardStats struct {
|
||||
TotalDocuments int64 `json:"total_documents"`
|
||||
DocumentsThisMonth int64 `json:"documents_this_month"`
|
||||
TrashCount int64 `json:"trash_count"`
|
||||
PendingDeleteRequests int64 `json:"pending_delete_requests"`
|
||||
RemindersDue int64 `json:"reminders_due"`
|
||||
RemindersUpcoming7d int64 `json:"reminders_upcoming_7d"`
|
||||
RetentionExpiring30d int64 `json:"retention_expiring_30d"`
|
||||
DocumentsByType []DocumentTypeCount `json:"documents_by_type"`
|
||||
}
|
||||
|
||||
// DocumentTypeCount is one entry of the documents_by_type breakdown.
|
||||
type DocumentTypeCount struct {
|
||||
DocumentTypeName string `json:"document_type_name"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// GetDashboardStats computes the aggregated dashboard key figures for a tenant.
|
||||
// Reminders are additionally scoped to the requesting user (reminders are
|
||||
// per-user like in ListReminders); the document/trash/retention figures are
|
||||
// tenant-wide.
|
||||
func (s *Store) GetDashboardStats(ctx context.Context, tenantID, userID int64) (*DashboardStats, error) {
|
||||
var stats DashboardStats
|
||||
|
||||
// Documents: active count + this-calendar-month count in one scan.
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE deleted_at IS NULL),
|
||||
COUNT(*) FILTER (WHERE deleted_at IS NULL AND created_at >= date_trunc('month', now())),
|
||||
COUNT(*) FILTER (WHERE deleted_at IS NOT NULL)
|
||||
FROM documents WHERE tenant_id = $1
|
||||
`, tenantID).Scan(&stats.TotalDocuments, &stats.DocumentsThisMonth, &stats.TrashCount)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: dashboard document counts: %w", err)
|
||||
}
|
||||
|
||||
// Open delete requests (awaiting confirmation or blocked by retention).
|
||||
err = s.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM document_delete_requests
|
||||
WHERE tenant_id = $1 AND status IN ('pending', 'blocked_retention')
|
||||
`, tenantID).Scan(&stats.PendingDeleteRequests)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: dashboard pending delete requests: %w", err)
|
||||
}
|
||||
|
||||
// Reminders (per user): due/overdue and upcoming within 7 days. "Not done"
|
||||
// means still status='open' (see reminders.go status semantics).
|
||||
err = s.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE due_date <= now()),
|
||||
COUNT(*) FILTER (WHERE due_date > now() AND due_date <= now() + interval '7 days')
|
||||
FROM reminders WHERE tenant_id = $1 AND user_id = $2 AND status = 'open'
|
||||
`, tenantID, userID).Scan(&stats.RemindersDue, &stats.RemindersUpcoming7d)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: dashboard reminders: %w", err)
|
||||
}
|
||||
|
||||
// Retention expiring within the next 30 days (active documents only).
|
||||
err = s.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM documents
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
AND retain_until IS NOT NULL
|
||||
AND retain_until >= current_date
|
||||
AND retain_until <= current_date + 30
|
||||
`, tenantID).Scan(&stats.RetentionExpiring30d)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: dashboard retention expiring: %w", err)
|
||||
}
|
||||
|
||||
// Documents by type — top 5 by count. Uses the structured document_types
|
||||
// entity via doc_type_id, falling back to the deprecated free-text doc_type
|
||||
// for Bestandsschutz, and "(ohne Typ)" when neither is set.
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT COALESCE(dt.name, NULLIF(d.doc_type, ''), '(ohne Typ)') AS type_name, COUNT(*) AS cnt
|
||||
FROM documents d
|
||||
LEFT JOIN document_types dt ON dt.id = d.doc_type_id
|
||||
WHERE d.tenant_id = $1 AND d.deleted_at IS NULL
|
||||
GROUP BY type_name
|
||||
ORDER BY cnt DESC, type_name ASC
|
||||
LIMIT 5
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: dashboard documents by type: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var c DocumentTypeCount
|
||||
if err := rows.Scan(&c.DocumentTypeName, &c.Count); err != nil {
|
||||
return nil, fmt.Errorf("storage: scan documents by type: %w", err)
|
||||
}
|
||||
stats.DocumentsByType = append(stats.DocumentsByType, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("storage: dashboard documents by type rows: %w", err)
|
||||
}
|
||||
if stats.DocumentsByType == nil {
|
||||
stats.DocumentsByType = []DocumentTypeCount{}
|
||||
}
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// documentDateMinYear mirrors dateMinYear in internal/api/date_extraction.go.
|
||||
// The whole scoring logic below is duplicated (rather than shared) because the
|
||||
// storage package must not import internal/api (that would create an import
|
||||
// cycle: api already depends on storage). Kept in sync with the api heuristic —
|
||||
// the same duplication pattern as heuristicTitle vs. titleFromOCRText.
|
||||
const (
|
||||
documentDateMinYear = 1990
|
||||
documentDateFutureToleranceDays = 2
|
||||
documentDateWindowRadius = 40
|
||||
documentDateScoreNoContext = 0.4
|
||||
)
|
||||
|
||||
// documentDateKeyword mirrors dateKeyword in internal/api/date_extraction.go.
|
||||
type documentDateKeyword struct {
|
||||
word string
|
||||
score float64
|
||||
}
|
||||
|
||||
var documentDateKeywords = []documentDateKeyword{
|
||||
{"rechnungsdatum", 0.9},
|
||||
{"belegdatum", 0.9},
|
||||
{"ausstellungsdatum", 0.9},
|
||||
{"rechnung vom", 0.9},
|
||||
{"beleg vom", 0.9},
|
||||
{"datum", 0.75},
|
||||
{"vom", 0.55},
|
||||
}
|
||||
|
||||
// documentDateGermanMonths mirrors dateGermanMonths in the api package.
|
||||
var documentDateGermanMonths = map[string]int{
|
||||
"januar": 1, "jan": 1,
|
||||
"februar": 2, "feb": 2,
|
||||
"märz": 3, "maerz": 3, "mär": 3, "mrz": 3,
|
||||
"april": 4, "apr": 4,
|
||||
"mai": 5,
|
||||
"juni": 6, "jun": 6,
|
||||
"juli": 7, "jul": 7,
|
||||
"august": 8, "aug": 8,
|
||||
"september": 9, "sep": 9, "sept": 9,
|
||||
"oktober": 10, "okt": 10,
|
||||
"november": 11, "nov": 11,
|
||||
"dezember": 12, "dez": 12,
|
||||
}
|
||||
|
||||
// documentDateCandidateRe mirrors dateCandidateRe in
|
||||
// internal/api/date_extraction.go. Recognised: DD.MM.YYYY, DD.MM.YY,
|
||||
// DD/MM/YYYY, YYYY-MM-DD and spelled-out German month names ("15. März 2026").
|
||||
var documentDateCandidateRe = regexp.MustCompile(
|
||||
`(?i)(?:\b(?P<gd>\d{1,2})\.(?P<gm>\d{1,2})\.(?P<gy>\d{4}|\d{2})\b)` +
|
||||
`|(?:\b(?P<sd>\d{1,2})/(?P<sm>\d{1,2})/(?P<sy>\d{4}|\d{2})\b)` +
|
||||
`|(?:\b(?P<iy>\d{4})-(?P<im>\d{1,2})-(?P<id>\d{1,2})\b)` +
|
||||
`|(?:\b(?P<td>\d{1,2})\.?\s+(?P<tmon>[A-Za-zäöüÄÖÜ]+)\.?\s+(?P<ty>\d{4})\b)`,
|
||||
)
|
||||
|
||||
// documentDateScoreForPosition mirrors scoreForDatePosition in the api package.
|
||||
func documentDateScoreForPosition(lowerText string, start int) float64 {
|
||||
lo := start - documentDateWindowRadius
|
||||
if lo < 0 {
|
||||
lo = 0
|
||||
}
|
||||
hi := start + documentDateWindowRadius
|
||||
if hi > len(lowerText) {
|
||||
hi = len(lowerText)
|
||||
}
|
||||
window := lowerText[lo:hi]
|
||||
for _, kw := range documentDateKeywords {
|
||||
if strings.Contains(window, kw.word) {
|
||||
return kw.score
|
||||
}
|
||||
}
|
||||
return documentDateScoreNoContext
|
||||
}
|
||||
|
||||
// documentDateParseMatch mirrors parseDateMatch in the api package.
|
||||
func documentDateParseMatch(names, m []string) (time.Time, bool) {
|
||||
now := time.Now()
|
||||
maxDate := now.AddDate(0, 0, documentDateFutureToleranceDays)
|
||||
var day, month, year int
|
||||
var monthName string
|
||||
for i, name := range names {
|
||||
if m[i] == "" {
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case "gd", "sd", "id", "td":
|
||||
day, _ = strconv.Atoi(m[i])
|
||||
case "gm", "sm", "im":
|
||||
month, _ = strconv.Atoi(m[i])
|
||||
case "gy", "sy":
|
||||
y, _ := strconv.Atoi(m[i])
|
||||
if len(m[i]) == 2 {
|
||||
y += 2000
|
||||
}
|
||||
year = y
|
||||
case "iy", "ty":
|
||||
year, _ = strconv.Atoi(m[i])
|
||||
case "tmon":
|
||||
monthName = m[i]
|
||||
}
|
||||
}
|
||||
if monthName != "" {
|
||||
mn, ok := documentDateGermanMonths[strings.ToLower(monthName)]
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
month = mn
|
||||
}
|
||||
if year < documentDateMinYear {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if month < 1 || month > 12 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if day < 1 || day > 31 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
d := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||
if d.Day() != day || int(d.Month()) != month || d.Year() != year {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if d.After(maxDate) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
|
||||
// documentDateFromTextWithScore mirrors extractDocumentDateWithScore in the api
|
||||
// package. Returns the best belegdatum candidate and its confidence.
|
||||
func documentDateFromTextWithScore(ocrText string) (best time.Time, score float64, found bool) {
|
||||
if ocrText == "" {
|
||||
return time.Time{}, 0, false
|
||||
}
|
||||
lower := strings.ToLower(ocrText)
|
||||
idxMatches := documentDateCandidateRe.FindAllStringSubmatchIndex(ocrText, -1)
|
||||
names := documentDateCandidateRe.SubexpNames()
|
||||
for _, loc := range idxMatches {
|
||||
m := make([]string, len(names))
|
||||
for g := range names {
|
||||
s, e := loc[2*g], loc[2*g+1]
|
||||
if s >= 0 {
|
||||
m[g] = ocrText[s:e]
|
||||
}
|
||||
}
|
||||
d, ok := documentDateParseMatch(names, m)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sc := documentDateScoreForPosition(lower, loc[0])
|
||||
if !found || sc > score {
|
||||
best, score, found = d, sc, true
|
||||
}
|
||||
}
|
||||
return best, score, found
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// ErrDuplicateContentHash is returned by CreateDocument when the tenant
|
||||
// already has an (active) document with the same content_hash — the DB-level
|
||||
// half of the duplicate-upload protection (see
|
||||
// migrations/003_documents_unique_hash.sql). The upload handler translates
|
||||
// this into an HTTP 409.
|
||||
var ErrDuplicateContentHash = errors.New("storage: document with this content hash already exists for tenant")
|
||||
|
||||
// ErrDocumentNotFound is returned when a tenant-scoped document lookup/update
|
||||
// affects zero rows (wrong id, wrong tenant, or already soft-deleted).
|
||||
var ErrDocumentNotFound = errors.New("storage: document not found")
|
||||
|
||||
// Document is the core archivdms record: a stored, indexed, and (optionally)
|
||||
// GoBD-retention-locked document. Unlike archivmail's `emails` table this is
|
||||
// intentionally generic — it is not tied to any particular ingestion source.
|
||||
//
|
||||
// Source / SourceRef are nullable placeholders for a later, purely optional
|
||||
// archivmail-pull importer (via archivmail's REST API) — no importer exists
|
||||
// yet, these columns just reserve the shape.
|
||||
type Document struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
Title string `json:"title"`
|
||||
DocType string `json:"doc_type,omitempty"` // deprecated free-text, kept for Bestandsschutz
|
||||
Correspondent string `json:"correspondent,omitempty"` // deprecated free-text, kept for Bestandsschutz
|
||||
DocTypeID *int64 `json:"doc_type_id,omitempty"`
|
||||
CorrespondentID *int64 `json:"correspondent_id,omitempty"`
|
||||
StoragePath string `json:"storage_path"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
OCRText string `json:"ocr_text,omitempty"`
|
||||
RetainUntil *time.Time `json:"retain_until,omitempty"`
|
||||
// DocumentDate is the recognised belegdatum (invoice/document date) parsed
|
||||
// from the OCR text at upload/reprocess time, if any. It is separate from
|
||||
// CreatedAt (the immutable scan/upload timestamp kept for GoBD
|
||||
// traceability) and drives the store/<tenant>/<yyyy>/<mm>/ archival path at
|
||||
// upload time. Nullable — nil when no plausible date was found in the text.
|
||||
DocumentDate *time.Time `json:"document_date,omitempty"`
|
||||
// DocumentDateScore is the confidence (0.4-0.9 automatic, 1.0 = manually
|
||||
// confirmed by a user via PUT .../document-date) of DocumentDate, or nil
|
||||
// when DocumentDate itself is nil / unknown (pre-existing rows from before
|
||||
// this column existed). Intended as a quality gate for the future
|
||||
// Buchhaltungs-Pull-API (only Score >= 0.75 auto-pullable) — see
|
||||
// migrations/025_document_date_score.sql. Never backfilled for existing
|
||||
// rows; NULL correctly means "no known score", not "score 0".
|
||||
DocumentDateScore *float64 `json:"document_date_score,omitempty"`
|
||||
Source string `json:"source,omitempty"` // e.g. "upload" | "archivmail_import"
|
||||
SourceRef string `json:"source_ref,omitempty"` // external ref, e.g. archivmail mail ID
|
||||
CreatedBy *int64 `json:"created_by,omitempty"`
|
||||
// TitleManuallySet is true once a user has explicitly renamed the document
|
||||
// via PATCH /api/documents/{id} (handleUpdateDocumentTitle). It gates
|
||||
// whether POST .../reprocess is allowed to overwrite the title with a
|
||||
// freshly re-derived one — see UpdateDocumentTitleAuto.
|
||||
TitleManuallySet bool `json:"title_manually_set"`
|
||||
// HasThumbnail is true once an eager preview thumbnail has been rendered
|
||||
// for this document (at upload or reprocess time, see internal/api
|
||||
// storeUploadedFile / ReprocessDocument) to
|
||||
// config.StorageConfig.ThumbnailPath()/<tenant>/<hash>.png. False for
|
||||
// documents whose thumbnail render failed/was skipped (unsupported
|
||||
// format, missing generator) or for any pre-existing document from before
|
||||
// this column existed — GET /api/documents/{id}/thumbnail still falls
|
||||
// back to on-demand lazy generation regardless of this flag, so a false
|
||||
// value never hard-blocks the preview, it's purely an optimisation hint.
|
||||
HasThumbnail bool `json:"has_thumbnail"`
|
||||
// ProcessingStatus spiegelt den Zustand der asynchronen Nachverarbeitung
|
||||
// (OCR/Taxonomie/Workflows) wider: queued | processing | done | failed
|
||||
// (siehe processing_jobs.go). Altbestand steht per Spalten-Default auf
|
||||
// 'done'. ACHTUNG: nur die Abfragen in dieser Datei (CreateDocument*,
|
||||
// GetDocument, ListDocuments) füllen das Feld — andere Selects (Suche,
|
||||
// Papierkorb, Retention) lassen es leer; "" ist vom Frontend wie 'done'
|
||||
// zu behandeln.
|
||||
ProcessingStatus string `json:"processing_status,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateDocumentRequest holds parameters for creating a new document.
|
||||
type CreateDocumentRequest struct {
|
||||
TenantID int64
|
||||
Title string
|
||||
DocType string
|
||||
Correspondent string
|
||||
StoragePath string
|
||||
ContentHash string
|
||||
OCRText string
|
||||
RetainUntil *time.Time
|
||||
// DocumentDate is the recognised belegdatum from the OCR text (nil if none).
|
||||
DocumentDate *time.Time
|
||||
// DocumentDateScore is the confidence for DocumentDate, mirrors the field
|
||||
// on Document (nil if DocumentDate is nil / unknown).
|
||||
DocumentDateScore *float64
|
||||
Source string
|
||||
SourceRef string
|
||||
// CreatedBy is the uploading user's ID, if known (nil for non-interactive
|
||||
// sources like the SFTP watcher). Used so an uploader can always see
|
||||
// their own document even before any permission-group grant exists for
|
||||
// it — otherwise a plain 'user' role could upload a document and
|
||||
// immediately lose visibility of it (see RecomputeVisibility/ACL model).
|
||||
CreatedBy *int64
|
||||
}
|
||||
|
||||
func (s *Store) initSchema(ctx context.Context) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
doc_type TEXT,
|
||||
correspondent TEXT,
|
||||
storage_path TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
ocr_text TEXT,
|
||||
retain_until DATE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_tenant ON documents(tenant_id);
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: create documents table: %w", err)
|
||||
}
|
||||
|
||||
// Duplicate protection at the DB level, on top of the filesystem-level
|
||||
// collision check performed by the upload handler (see
|
||||
// migrations/003_documents_unique_hash.sql).
|
||||
_, err = s.db.Exec(ctx, `
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_tenant_hash ON documents(tenant_id, content_hash);
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: create documents unique hash index: %w", err)
|
||||
}
|
||||
|
||||
// Space for a later, optional archivmail-pull importer (see Document doc
|
||||
// comment). Added as nullable columns now, idempotently, no importer yet.
|
||||
_, err = s.db.Exec(ctx, `
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS source TEXT;
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS source_ref TEXT;
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: alter documents table: %w", err)
|
||||
}
|
||||
|
||||
// created_by: nullable, no FK (users can be deleted independently of
|
||||
// their historical uploads, same Bestandsschutz reasoning as elsewhere
|
||||
// in this schema). Lets ListDocuments always show the uploader their own
|
||||
// document even with zero document_visibility rows (see CreateDocumentRequest).
|
||||
_, err = s.db.Exec(ctx, `
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS created_by BIGINT;
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: alter documents add created_by: %w", err)
|
||||
}
|
||||
|
||||
// title_manually_set: tracks whether a user has explicitly renamed the
|
||||
// document (via PATCH .../title) versus the title still being whatever was
|
||||
// auto-derived from OCR text at upload time. Reprocess only re-derives the
|
||||
// title from fresh OCR when this is false, so a user's manual rename is
|
||||
// never silently overwritten. Defaults to false for all existing rows —
|
||||
// deliberate: we cannot distinguish old manual renames from old
|
||||
// auto-derived titles, and false is the safe choice that lets reprocess
|
||||
// improve stale/garbled auto-derived titles from before the OCR
|
||||
// rotation/deskew fix (see DEVLOG).
|
||||
_, err = s.db.Exec(ctx, `
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS title_manually_set BOOLEAN NOT NULL DEFAULT false;
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: alter documents add title_manually_set: %w", err)
|
||||
}
|
||||
|
||||
// document_date: the belegdatum (invoice/document date) recognised from the
|
||||
// OCR text, separate from created_at (the immutable scan/upload timestamp).
|
||||
// Nullable — nil for existing rows and for documents where no plausible date
|
||||
// was found. Drives the store/<tenant>/<yyyy>/<mm>/ archival path at upload
|
||||
// time (see internal/api storeUploadedFile); reprocess refreshes the column
|
||||
// but never moves the already-archived WORM file. See
|
||||
// migrations/019_document_date.sql.
|
||||
_, err = s.db.Exec(ctx, `
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS document_date DATE;
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: alter documents add document_date: %w", err)
|
||||
}
|
||||
|
||||
// document_date_score: confidence (0.4-0.9 automatic keyword-proximity
|
||||
// heuristic, 1.0 once a user manually confirms/overrides the date via PUT
|
||||
// .../document-date) for document_date. Nullable, no backfill for existing
|
||||
// rows — NULL means "score unknown", not "score 0". Reserved as a quality
|
||||
// gate for the future Buchhaltungs-Pull-API (only Score >= 0.75 pullable).
|
||||
// See migrations/025_document_date_score.sql.
|
||||
_, err = s.db.Exec(ctx, `
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS document_date_score NUMERIC;
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: alter documents add document_date_score: %w", err)
|
||||
}
|
||||
|
||||
// Structured taxonomy entities (tags/document_types/correspondents) plus
|
||||
// the barcode-recognition columns on documents (see
|
||||
// migrations/005_taxonomy.sql / lazy-splashing-puppy plan).
|
||||
if err := s.initTaxonomySchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Custom fields (custom_field_defs / document_type_fields /
|
||||
// document_field_values — see migrations/006_custom_fields.sql).
|
||||
if err := s.initCustomFieldsSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Papierkorb + gestaffeltes Löschkonzept (soft-delete + Vier-Augen-Prinzip
|
||||
// für finales Löschen — see migrations/007_trash.sql).
|
||||
if err := s.initTrashSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Klassifizierungsvorlagen (classification templates — see
|
||||
// migrations/011_classification_templates.sql). Depends on document_types
|
||||
// (initTaxonomySchema) and custom_field_defs (initCustomFieldsSchema)
|
||||
// created above, so it is wired in AFTER them.
|
||||
if err := s.initClassificationTemplatesSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Workflows / Consumption-Regeln (see migrations/012_workflows.sql). Wired
|
||||
// in AFTER classification templates because a workflow action can reference
|
||||
// a classification template (apply_classification_template).
|
||||
if err := s.initWorkflowsSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Heuristische Metadaten-Vorschläge (metadata_suggestions — see
|
||||
// migrations/012_metadata_suggestions.sql). Wired in AFTER documents/taxonomy
|
||||
// exist, since suggestions score taxonomy entities against a document.
|
||||
if err := s.initMetadataSuggestionsSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pro-Mandant konfigurierbare Anbindung an einen EXTERNEN Ollama-Server
|
||||
// (tenant_ollama_config — see migrations/017_tenant_ollama_config.sql).
|
||||
// Gate für den optionalen 'ollama'-Provider der Metadaten-Vorschläge.
|
||||
if err := s.initOllamaConfigSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Freitext-Notizen pro Dokument (document_notes — see
|
||||
// migrations/013_document_notes.sql). Wired in AFTER documents exists since
|
||||
// it FK-references documents(id).
|
||||
if err := s.initDocumentNotesSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Gespeicherte Suchansichten (saved_views — SavedViews, Paperless-ngx
|
||||
// inspiriert). Tenant-/user-scoped, hängt an keiner anderen Tabelle, daher
|
||||
// zuletzt eingehängt.
|
||||
if err := s.initSavedViewsSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// OCR-Wortpositionen (ocr_words — see migrations/024_ocr_words.sql), Phase
|
||||
// 2 of the OCR text-highlight/overlay feature. Wired in AFTER documents
|
||||
// exists since it FK-references documents(id) ON DELETE CASCADE.
|
||||
if err := s.initOCRWordsSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Digitale Akten (digitaler Aktenordner — see
|
||||
// migrations/018_akten.sql / project_akte_konzept_plan.md). Wired in AFTER
|
||||
// documents + correspondents exist: initAktenSchema creates the akten table
|
||||
// (FK to correspondents) and then ALTERs documents to add akte_id (FK to
|
||||
// akten), so both referenced tables must already exist.
|
||||
if err := s.initAktenSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ML-Retraining-Klassifizierung Phase 1 (Naive-Bayes, ergänzt die
|
||||
// Regel-Engine — see migrations/020_ml_classifier.sql). Wired in AFTER
|
||||
// initTaxonomySchema since it FK-references nothing directly but ALTERs
|
||||
// documents/document_tags which must already exist.
|
||||
if err := s.initMLClassifierSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// has_thumbnail: tracks whether an eager preview thumbnail was rendered at
|
||||
// upload/reprocess time (see storeUploadedFile/ReprocessDocument in
|
||||
// internal/api). Defaults to false for all existing rows — deliberate:
|
||||
// GET /api/documents/{id}/thumbnail already falls back to on-demand lazy
|
||||
// generation on a cache miss regardless of this flag (see
|
||||
// internal/thumbnail), so no backfill migration is needed here, this
|
||||
// column is purely a "was it pre-rendered" hint for the frontend.
|
||||
_, err = s.db.Exec(ctx, `
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS has_thumbnail BOOLEAN NOT NULL DEFAULT false;
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: alter documents add has_thumbnail: %w", err)
|
||||
}
|
||||
|
||||
// GoBD-Aufbewahrungsregeln (retention rules / "Disposition Schedules" —
|
||||
// see migrations/022_retention_rules.sql). Wired in AFTER initTaxonomySchema
|
||||
// (FK to document_types) and AFTER initTrashSchema (retain_until/deleted_at
|
||||
// on documents must already exist — the rules engine only ever FEEDS
|
||||
// retain_until, the existing trash + Vier-Augen flow performs the actual
|
||||
// disposition).
|
||||
if err := s.initRetentionRulesSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Mandanten-Job-Queue für die asynchrone Nachverarbeitung (processing_jobs
|
||||
// + documents.processing_status — see migrations/023_processing_jobs.sql).
|
||||
// Zuletzt eingehängt: die Tabelle FK-referenziert documents(id), das muss
|
||||
// also bereits existieren.
|
||||
if err := s.initProcessingJobsSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateDocument inserts a new document and returns it.
|
||||
func (s *Store) CreateDocument(ctx context.Context, req CreateDocumentRequest) (*Document, error) {
|
||||
var d Document
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO documents (tenant_id, title, doc_type, correspondent, storage_path, content_hash, ocr_text, retain_until, document_date, document_date_score, source, source_ref, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id, tenant_id, title, COALESCE(doc_type, ''), COALESCE(correspondent, ''), doc_type_id, correspondent_id, storage_path, content_hash,
|
||||
COALESCE(ocr_text, ''), retain_until, document_date, document_date_score, COALESCE(source, ''), COALESCE(source_ref, ''), created_by, title_manually_set, has_thumbnail, processing_status, created_at, updated_at
|
||||
`, req.TenantID, req.Title, nullIfEmpty(req.DocType), nullIfEmpty(req.Correspondent), req.StoragePath, req.ContentHash,
|
||||
nullIfEmpty(req.OCRText), req.RetainUntil, req.DocumentDate, req.DocumentDateScore, nullIfEmpty(req.Source), nullIfEmpty(req.SourceRef), req.CreatedBy,
|
||||
).Scan(&d.ID, &d.TenantID, &d.Title, &d.DocType, &d.Correspondent, &d.DocTypeID, &d.CorrespondentID, &d.StoragePath, &d.ContentHash,
|
||||
&d.OCRText, &d.RetainUntil, &d.DocumentDate, &d.DocumentDateScore, &d.Source, &d.SourceRef, &d.CreatedBy, &d.TitleManuallySet, &d.HasThumbnail, &d.ProcessingStatus, &d.CreatedAt, &d.UpdatedAt)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return nil, ErrDuplicateContentHash
|
||||
}
|
||||
return nil, fmt.Errorf("storage: create document: %w", err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// GetDocument retrieves a document by ID, scoped to tenant.
|
||||
func (s *Store) GetDocument(ctx context.Context, id, tenantID int64) (*Document, error) {
|
||||
var d Document
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, title, COALESCE(doc_type, ''), COALESCE(correspondent, ''), doc_type_id, correspondent_id, storage_path, content_hash,
|
||||
COALESCE(ocr_text, ''), retain_until, document_date, document_date_score, COALESCE(source, ''), COALESCE(source_ref, ''), created_by, title_manually_set, has_thumbnail, processing_status, created_at, updated_at
|
||||
FROM documents WHERE id = $1 AND tenant_id = $2
|
||||
`, id, tenantID).Scan(&d.ID, &d.TenantID, &d.Title, &d.DocType, &d.Correspondent, &d.DocTypeID, &d.CorrespondentID, &d.StoragePath, &d.ContentHash,
|
||||
&d.OCRText, &d.RetainUntil, &d.DocumentDate, &d.DocumentDateScore, &d.Source, &d.SourceRef, &d.CreatedBy, &d.TitleManuallySet, &d.HasThumbnail, &d.ProcessingStatus, &d.CreatedAt, &d.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: get document: %w", err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// DocumentExistsByHash reports whether the tenant already has a document with
|
||||
// the given content_hash. This mirrors the (tenant_id, content_hash) unique
|
||||
// index (deleted_at is intentionally NOT filtered, so the result matches what
|
||||
// CreateDocument's INSERT would enforce) and lets the upload pipeline detect a
|
||||
// duplicate BEFORE spending OCR/Tesseract cycles on the file.
|
||||
func (s *Store) DocumentExistsByHash(ctx context.Context, tenantID int64, contentHash string) (bool, error) {
|
||||
var exists bool
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT EXISTS (SELECT 1 FROM documents WHERE tenant_id = $1 AND content_hash = $2)
|
||||
`, tenantID, contentHash).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("storage: check document hash: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// ListDocuments returns all documents for a tenant, newest first.
|
||||
//
|
||||
// aclUserID applies the group-resolved document ACL: when non-nil, only
|
||||
// documents visible to that user via document_visibility (through their
|
||||
// permission group memberships) are returned. Callers pass a non-nil value
|
||||
// only for role 'user'; domain_admin/superadmin pass nil to see every document
|
||||
// in the tenant unfiltered (roles remain the outer boundary — see
|
||||
// permissions.go).
|
||||
func (s *Store) ListDocuments(ctx context.Context, tenantID int64, aclUserID *int64) ([]Document, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, tenant_id, title, COALESCE(doc_type, ''), COALESCE(correspondent, ''), doc_type_id, correspondent_id, storage_path, content_hash,
|
||||
COALESCE(ocr_text, ''), retain_until, document_date, document_date_score, COALESCE(source, ''), COALESCE(source_ref, ''), created_by, title_manually_set, has_thumbnail, processing_status, created_at, updated_at
|
||||
FROM documents
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
AND ($2::bigint IS NULL OR documents.created_by = $2 OR EXISTS (
|
||||
SELECT 1 FROM document_visibility dv
|
||||
JOIN permission_group_members pgm ON pgm.group_id = dv.group_id
|
||||
WHERE dv.document_id = documents.id AND pgm.user_id = $2
|
||||
))
|
||||
ORDER BY created_at DESC
|
||||
`, tenantID, aclUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: list documents: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]Document, 0)
|
||||
for rows.Next() {
|
||||
var d Document
|
||||
if err := rows.Scan(&d.ID, &d.TenantID, &d.Title, &d.DocType, &d.Correspondent, &d.DocTypeID, &d.CorrespondentID, &d.StoragePath, &d.ContentHash,
|
||||
&d.OCRText, &d.RetainUntil, &d.DocumentDate, &d.DocumentDateScore, &d.Source, &d.SourceRef, &d.CreatedBy, &d.TitleManuallySet, &d.HasThumbnail, &d.ProcessingStatus, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("storage: scan document: %w", err)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// IsDocumentVisible reports whether the given user may see the document, using
|
||||
// exactly the same rule as ListDocuments' ACL predicate (own upload OR a
|
||||
// document_visibility row resolved through the user's permission groups).
|
||||
// Always tenant-scoped: a document of another tenant is never visible, even to
|
||||
// its own uploader. Callers pass userID only for role 'user'; domain_admin /
|
||||
// superadmin skip this check (roles remain the outer ACL boundary).
|
||||
func (s *Store) IsDocumentVisible(ctx context.Context, id, tenantID, userID int64) (bool, error) {
|
||||
var visible bool
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM documents d
|
||||
WHERE d.id = $1 AND d.tenant_id = $2 AND d.deleted_at IS NULL
|
||||
AND (d.created_by = $3 OR EXISTS (
|
||||
SELECT 1 FROM document_visibility dv
|
||||
JOIN permission_group_members pgm ON pgm.group_id = dv.group_id
|
||||
WHERE dv.document_id = d.id AND pgm.user_id = $3
|
||||
))
|
||||
)
|
||||
`, id, tenantID, userID).Scan(&visible)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("storage: check document visibility: %w", err)
|
||||
}
|
||||
return visible, nil
|
||||
}
|
||||
|
||||
// DocumentTaxonomyNames resolves the structured document-type and correspondent
|
||||
// names of a document, tenant-scoped. Empty strings when unassigned. Used by
|
||||
// the single-document export (metadata.json) so the ZIP carries readable names
|
||||
// instead of raw IDs.
|
||||
func (s *Store) DocumentTaxonomyNames(ctx context.Context, id, tenantID int64) (docType, correspondent string, err error) {
|
||||
err = s.db.QueryRow(ctx, `
|
||||
SELECT COALESCE(dt.name, ''), COALESCE(c.name, '')
|
||||
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 d.id = $1 AND d.tenant_id = $2
|
||||
`, id, tenantID).Scan(&docType, &correspondent)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("storage: document taxonomy names: %w", err)
|
||||
}
|
||||
return docType, correspondent, nil
|
||||
}
|
||||
|
||||
// UpdateDocumentTitle renames a document, scoped to tenant, and marks the
|
||||
// title as manually set (title_manually_set = true). Use this for the
|
||||
// user-facing PATCH .../title endpoint only — it permanently opts the
|
||||
// document out of automatic title re-derivation by POST .../reprocess. Title
|
||||
// is not part of the ACL, so this only needs an index re-sync (mirrors
|
||||
// SetDocumentCorrespondent in taxonomy.go), not a visibility recompute.
|
||||
func (s *Store) UpdateDocumentTitle(ctx context.Context, id, tenantID int64, title string) error {
|
||||
tag, err := s.db.Exec(ctx, `UPDATE documents SET title = $1, title_manually_set = true, updated_at = now() WHERE id = $2 AND tenant_id = $3`, title, id, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: update document title: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("storage: update document title: %w", ErrDocumentNotFound)
|
||||
}
|
||||
s.SyncIndex(ctx, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateDocumentTitleAuto renames a document without touching
|
||||
// title_manually_set. Used only by POST .../reprocess to re-derive a title
|
||||
// from freshly re-extracted OCR text — callers must already have checked
|
||||
// doc.TitleManuallySet is false before calling this, otherwise a user's
|
||||
// manual rename would be silently overwritten.
|
||||
func (s *Store) UpdateDocumentTitleAuto(ctx context.Context, id, tenantID int64, title string) error {
|
||||
tag, err := s.db.Exec(ctx, `UPDATE documents SET title = $1, updated_at = now() WHERE id = $2 AND tenant_id = $3`, title, id, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: update document title (auto): %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("storage: update document title (auto): %w", ErrDocumentNotFound)
|
||||
}
|
||||
s.SyncIndex(ctx, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateDocumentOCRText replaces a document's ocr_text, scoped to tenant.
|
||||
// Used by the re-processing endpoint (POST /api/documents/{id}/reprocess)
|
||||
// after re-running OCR on an already-archived file — the WORM file itself is
|
||||
// never touched, only this derived metadata column is refreshed. OCR text is
|
||||
// not part of the ACL, so this only needs an index re-sync (mirrors
|
||||
// UpdateDocumentTitle), not a visibility recompute.
|
||||
func (s *Store) UpdateDocumentOCRText(ctx context.Context, id, tenantID int64, ocrText string) error {
|
||||
tag, err := s.db.Exec(ctx, `UPDATE documents SET ocr_text = $1, updated_at = now() WHERE id = $2 AND tenant_id = $3`, nullIfEmpty(ocrText), id, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: update document ocr_text: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("storage: update document ocr_text: %w", ErrDocumentNotFound)
|
||||
}
|
||||
s.SyncIndex(ctx, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateDocumentDate replaces a document's recognised belegdatum
|
||||
// (document_date) and its confidence (document_date_score), scoped to tenant.
|
||||
// Used by the re-processing endpoint after re-running OCR so the metadata
|
||||
// reflects a freshly recognised date — the WORM file and its
|
||||
// store/<yyyy>/<mm>/ path are NEVER moved, only this derived column is
|
||||
// refreshed. Passing a nil date clears both date and score. score should be
|
||||
// 1.0 when the caller is a manual user confirmation (PUT .../document-date),
|
||||
// or the heuristic's own confidence (0.4-0.9) for automatic
|
||||
// extract/reprocess/job-queue callers — never carry over a stale automatic
|
||||
// score after a manual override. document_date is not part of the ACL, so
|
||||
// this only needs an index re-sync (mirrors UpdateDocumentOCRText), not a
|
||||
// visibility recompute.
|
||||
func (s *Store) UpdateDocumentDate(ctx context.Context, id, tenantID int64, date *time.Time, score *float64) error {
|
||||
if date == nil {
|
||||
score = nil
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `UPDATE documents SET document_date = $1, document_date_score = $2, updated_at = now() WHERE id = $3 AND tenant_id = $4`, date, score, id, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: update document date: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("storage: update document date: %w", ErrDocumentNotFound)
|
||||
}
|
||||
s.SyncIndex(ctx, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDocumentHasThumbnail records whether an eager preview thumbnail was
|
||||
// successfully rendered for this document, scoped to tenant. Used by
|
||||
// storeUploadedFile (after upload) and ReprocessDocument (to backfill/repair
|
||||
// a previously missing thumbnail) — see internal/api/document_handlers.go.
|
||||
// has_thumbnail is a pure UI hint (not indexed content, not part of the ACL),
|
||||
// so unlike UpdateDocumentTitle/OCRText/Date this deliberately does NOT call
|
||||
// SyncIndex — no Manticore reindex is needed for a thumbnail flag.
|
||||
func (s *Store) SetDocumentHasThumbnail(ctx context.Context, id, tenantID int64, hasThumbnail bool) error {
|
||||
tag, err := s.db.Exec(ctx, `UPDATE documents SET has_thumbnail = $1, updated_at = now() WHERE id = $2 AND tenant_id = $3`, hasThumbnail, id, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: set document has_thumbnail: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("storage: set document has_thumbnail: %w", ErrDocumentNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteDocument removes a document, scoped to tenant.
|
||||
func (s *Store) DeleteDocument(ctx context.Context, id, tenantID int64) error {
|
||||
tag, err := s.db.Exec(ctx, `DELETE FROM documents WHERE id = $1 AND tenant_id = $2`, id, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: delete document: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("storage: document not found or not owned by tenant")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// nullIfEmptyPtr maps a *string to a NULL-able SQL argument: nil or a
|
||||
// whitespace-only value becomes NULL, otherwise the trimmed string is stored.
|
||||
func nullIfEmptyPtr(s *string) any {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(*s) == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.TrimSpace(*s)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"archivdms/internal/index"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Full-text search index sync (Phase 1, see internal/index).
|
||||
//
|
||||
// Postgres is the single source of truth; these helpers keep the secondary
|
||||
// per-tenant Manticore index in step. They are strictly best-effort: an index
|
||||
// failure is logged and swallowed, NEVER returned to the caller, so a search
|
||||
// backend hiccup can never block or fail a document write. When no indexer is
|
||||
// configured (s.indexer == nil) every helper is a no-op.
|
||||
|
||||
// SyncIndex re-projects a document (with its tags + resolved ACL groups) into
|
||||
// the search index. Safe to call after any change that affects an indexed
|
||||
// field: create, tag attach/detach, doc_type/correspondent change, custom
|
||||
// fields, or an ACL recompute. Best-effort — errors are logged, not returned.
|
||||
func (s *Store) SyncIndex(ctx context.Context, documentID int64) {
|
||||
if s.indexer == nil {
|
||||
return
|
||||
}
|
||||
doc, err := s.buildDocumentDoc(ctx, documentID)
|
||||
if err != nil {
|
||||
s.logIndexWarn("build index doc", documentID, err)
|
||||
return
|
||||
}
|
||||
if doc == nil {
|
||||
// Row vanished (or tombstoned) — treat as a delete.
|
||||
return
|
||||
}
|
||||
if err := s.indexer.ForTenant(doc.TenantID).IndexSync(ctx, *doc); err != nil {
|
||||
s.logIndexWarn("index sync", documentID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteFromIndex removes a document from the search index. Used on final
|
||||
// (executed) deletion — GoBD-critical: a purged document must not remain
|
||||
// findable. Best-effort — errors are logged, not returned. tenantID is passed
|
||||
// explicitly because the DB row may already be gone/tombstoned.
|
||||
func (s *Store) DeleteFromIndex(ctx context.Context, documentID, tenantID int64) {
|
||||
if s.indexer == nil {
|
||||
return
|
||||
}
|
||||
if err := s.indexer.ForTenant(tenantID).Delete(ctx, documentID); err != nil {
|
||||
s.logIndexWarn("index delete", documentID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildDocumentDoc assembles the index projection for a single document from
|
||||
// the documents row plus its tags (document_tags/tags) and resolved ACL groups
|
||||
// (document_visibility). Returns (nil, nil) if the document does not exist.
|
||||
func (s *Store) buildDocumentDoc(ctx context.Context, documentID int64) (*index.DocumentDoc, error) {
|
||||
var d index.DocumentDoc
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, title, COALESCE(doc_type, ''), COALESCE(correspondent, ''),
|
||||
doc_type_id, correspondent_id, COALESCE(ocr_text, ''), retain_until, created_at, updated_at
|
||||
FROM documents WHERE id = $1
|
||||
`, documentID).Scan(&d.ID, &d.TenantID, &d.Title, &d.DocType, &d.Correspondent,
|
||||
&d.DocTypeID, &d.CorrespondentID, &d.OCRText, &d.RetainUntil, &d.CreatedAt, &d.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Tags (names + ids), tenant-scoped via the join to tags.
|
||||
tagRows, err := s.db.Query(ctx, `
|
||||
SELECT t.id, t.name FROM tags t
|
||||
JOIN document_tags dt ON dt.tag_id = t.id
|
||||
WHERE dt.document_id = $1 AND t.tenant_id = $2
|
||||
ORDER BY t.name ASC
|
||||
`, documentID, d.TenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for tagRows.Next() {
|
||||
var id int64
|
||||
var name string
|
||||
if err := tagRows.Scan(&id, &name); err != nil {
|
||||
tagRows.Close()
|
||||
return nil, err
|
||||
}
|
||||
d.TagIDs = append(d.TagIDs, id)
|
||||
d.Tags = append(d.Tags, name)
|
||||
}
|
||||
tagRows.Close()
|
||||
if err := tagRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resolved ACL groups (materialised visibility).
|
||||
aclRows, err := s.db.Query(ctx, `
|
||||
SELECT group_id FROM document_visibility WHERE document_id = $1 ORDER BY group_id
|
||||
`, documentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for aclRows.Next() {
|
||||
var gid int64
|
||||
if err := aclRows.Scan(&gid); err != nil {
|
||||
aclRows.Close()
|
||||
return nil, err
|
||||
}
|
||||
d.ACLGroupIDs = append(d.ACLGroupIDs, gid)
|
||||
}
|
||||
aclRows.Close()
|
||||
if err := aclRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// ErrNoIndexer is returned by reindex helpers when no search index is wired
|
||||
// into the store. Unlike the request-path sync helpers (which degrade to a
|
||||
// silent no-op when s.indexer == nil), an explicit reindex must fail loudly so
|
||||
// an operator never mistakes a no-op for a successful rebuild.
|
||||
var ErrNoIndexer = errors.New("storage: no search index configured")
|
||||
|
||||
// ReindexTenant rebuilds the full-text search index for a single tenant from
|
||||
// Postgres (the source of truth). It streams all non-deleted documents of the
|
||||
// tenant in ascending-id batches (keyset pagination, batchSize rows at a time)
|
||||
// so memory stays bounded even for very large tenants, projects each via
|
||||
// buildDocumentDoc and upserts it through the tenant's Indexer.
|
||||
//
|
||||
// progress, if non-nil, is invoked after each successfully indexed document
|
||||
// with (done, total) so callers can log progress. Returns the number of
|
||||
// documents indexed. Fails with ErrNoIndexer when no indexer is configured.
|
||||
func (s *Store) ReindexTenant(ctx context.Context, tenantID int64, batchSize int, progress func(done, total int)) (int, error) {
|
||||
if s.indexer == nil {
|
||||
return 0, ErrNoIndexer
|
||||
}
|
||||
if batchSize <= 0 {
|
||||
batchSize = 500
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := s.db.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM documents WHERE tenant_id = $1 AND deleted_at IS NULL`,
|
||||
tenantID,
|
||||
).Scan(&total); err != nil {
|
||||
return 0, fmt.Errorf("storage: reindex count tenant %d: %w", tenantID, err)
|
||||
}
|
||||
|
||||
indexer := s.indexer.ForTenant(tenantID)
|
||||
|
||||
done := 0
|
||||
var lastID int64
|
||||
for {
|
||||
ids, err := s.reindexDocumentIDs(ctx, tenantID, lastID, batchSize)
|
||||
if err != nil {
|
||||
return done, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
break
|
||||
}
|
||||
for _, id := range ids {
|
||||
doc, err := s.buildDocumentDoc(ctx, id)
|
||||
if err != nil {
|
||||
return done, fmt.Errorf("storage: reindex build doc id=%d: %w", id, err)
|
||||
}
|
||||
if doc == nil {
|
||||
// Row vanished/tombstoned between the id scan and now — skip.
|
||||
continue
|
||||
}
|
||||
if err := indexer.IndexSync(ctx, *doc); err != nil {
|
||||
return done, fmt.Errorf("storage: reindex index doc id=%d: %w", id, err)
|
||||
}
|
||||
done++
|
||||
if progress != nil {
|
||||
progress(done, total)
|
||||
}
|
||||
}
|
||||
lastID = ids[len(ids)-1]
|
||||
}
|
||||
|
||||
return done, nil
|
||||
}
|
||||
|
||||
// reindexDocumentIDs returns up to limit non-deleted document IDs for a tenant
|
||||
// with id > afterID, ascending (keyset pagination).
|
||||
func (s *Store) reindexDocumentIDs(ctx context.Context, tenantID, afterID int64, limit int) ([]int64, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id FROM documents
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL AND id > $2
|
||||
ORDER BY id ASC
|
||||
LIMIT $3
|
||||
`, tenantID, afterID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: reindex list ids tenant %d: %w", tenantID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("storage: reindex scan id: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) logIndexWarn(op string, documentID int64, err error) {
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("index sync failed", "op", op, "document_id", documentID, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"archivdms/internal/matching"
|
||||
)
|
||||
|
||||
// ErrSuggestionNotFound is returned when no metadata_suggestions row exists
|
||||
// for a document (GetLatestSuggestion) or the id/tenant scope does not match
|
||||
// (MarkSuggestionReviewed).
|
||||
var ErrSuggestionNotFound = errors.New("storage: metadata suggestion not found")
|
||||
|
||||
// suggestionFloor is the minimum fuzzy score (0..1) at which a
|
||||
// tag/document_type/correspondent is surfaced as a NON-binding suggestion.
|
||||
// It sits deliberately BELOW matching.FuzzyThreshold (currently 0.85, the
|
||||
// auto-assign confidence): candidates at/above FuzzyThreshold that were not
|
||||
// already auto-assigned (e.g. a second document_type that also matched, or a
|
||||
// name-only near-match on an entity whose configured algorithm isn't fuzzy)
|
||||
// are strong suggestions; candidates in [suggestionFloor, FuzzyThreshold) are
|
||||
// the near-misses this feature exists to expose for manual review. 0.55 keeps
|
||||
// noise low while still catching typo-level OCR differences.
|
||||
const suggestionFloor = 0.55
|
||||
|
||||
// maxSuggestionCandidates caps how many candidates are kept per category
|
||||
// (tags / document_types / correspondents), sorted by score descending.
|
||||
const maxSuggestionCandidates = 5
|
||||
|
||||
// autoGeneratedTitlePattern matches the timestamp placeholder title produced
|
||||
// by titleFromOCRText ("Scan DD.MM.YYYY HH:MM") when no meaningful heading
|
||||
// could be derived on ingest. A current title matching this is treated as
|
||||
// "not a real title yet", so a re-derived title is suggested.
|
||||
var autoGeneratedTitlePattern = regexp.MustCompile(`^Scan \d{2}\.\d{2}\.\d{4} \d{2}:\d{2}$`)
|
||||
|
||||
// SuggestionCandidate is one scored, non-binding metadata suggestion for a
|
||||
// single taxonomy entity. Score is the fuzzy similarity in [0,1].
|
||||
type SuggestionCandidate struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Score float64 `json:"score"`
|
||||
Explanation []string `json:"explanation,omitempty"`
|
||||
}
|
||||
|
||||
// DocumentDateCandidate is a non-binding belegdatum (invoice/document date)
|
||||
// suggestion re-derived from the OCR text. Date is the ISO date (YYYY-MM-DD)
|
||||
// the frontend can apply via PUT /api/documents/{id}/document-date; Score is a
|
||||
// fixed heuristic confidence (the regex date scanner has no per-match score).
|
||||
type DocumentDateCandidate struct {
|
||||
Date string `json:"date"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// SuggestionPayload is the JSONB body persisted in metadata_suggestions.suggestion.
|
||||
// A nil Title means no title suggestion was made (current title already looks
|
||||
// human-authored). The candidate slices are always non-nil (possibly empty).
|
||||
// DocumentDateCandidate is nil when the document already has a belegdatum set or
|
||||
// no plausible date could be recognised in the OCR text.
|
||||
type SuggestionPayload struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
DocTypeCandidates []SuggestionCandidate `json:"doc_type_candidates"`
|
||||
CorrespondentCandidates []SuggestionCandidate `json:"correspondent_candidates"`
|
||||
TagCandidates []SuggestionCandidate `json:"tag_candidates"`
|
||||
DocumentDateCandidate *DocumentDateCandidate `json:"document_date_candidate,omitempty"`
|
||||
}
|
||||
|
||||
// MetadataSuggestion is one persisted suggestion run for a document. It is a
|
||||
// log/cache of what the heuristic provider proposed — applying an accepted
|
||||
// field goes through the normal edit endpoints, NOT through this row.
|
||||
type MetadataSuggestion struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
DocumentID int64 `json:"document_id"`
|
||||
Provider string `json:"provider"`
|
||||
RequestedBy *int64 `json:"requested_by,omitempty"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
Suggestion SuggestionPayload `json:"suggestion"`
|
||||
Status string `json:"status"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
||||
ReviewedBy *int64 `json:"reviewed_by,omitempty"`
|
||||
}
|
||||
|
||||
// initMetadataSuggestionsSchema creates the metadata_suggestions table.
|
||||
// Idempotent, called from (*Store).initSchema AFTER the documents/taxonomy
|
||||
// schema exists. Documented (not executed) in
|
||||
// migrations/012_metadata_suggestions.sql.
|
||||
func (s *Store) initMetadataSuggestionsSchema(ctx context.Context) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS metadata_suggestions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
document_id BIGINT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'heuristic',
|
||||
requested_by BIGINT,
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
suggestion JSONB NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','reviewed')),
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
reviewed_by BIGINT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_metadata_suggestions_document ON metadata_suggestions(document_id);
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: create metadata suggestions table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const metadataSuggestionCols = `id, tenant_id, document_id, provider, requested_by, requested_at, suggestion, status, reviewed_at, reviewed_by`
|
||||
|
||||
func scanMetadataSuggestion(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (*MetadataSuggestion, error) {
|
||||
var m MetadataSuggestion
|
||||
var payload []byte
|
||||
if err := row.Scan(&m.ID, &m.TenantID, &m.DocumentID, &m.Provider, &m.RequestedBy,
|
||||
&m.RequestedAt, &payload, &m.Status, &m.ReviewedAt, &m.ReviewedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Suggestion = SuggestionPayload{
|
||||
DocTypeCandidates: make([]SuggestionCandidate, 0),
|
||||
CorrespondentCandidates: make([]SuggestionCandidate, 0),
|
||||
TagCandidates: make([]SuggestionCandidate, 0),
|
||||
}
|
||||
if len(payload) > 0 {
|
||||
if err := json.Unmarshal(payload, &m.Suggestion); err != nil {
|
||||
return nil, fmt.Errorf("storage: unmarshal suggestion payload: %w", err)
|
||||
}
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// GenerateHeuristicSuggestions builds a fresh, rule-based (no LLM) metadata
|
||||
// suggestion for a document: it fuzzy-scores every taxonomy entity's name (and
|
||||
// its configured match_pattern, if any) against the document's title+OCR text,
|
||||
// surfaces the near-misses above suggestionFloor that are NOT already assigned,
|
||||
// and — if the current title still looks auto-generated — proposes a
|
||||
// re-derived title. The result is persisted as a metadata_suggestions row and
|
||||
// returned. requestedBy may be nil for non-interactive callers.
|
||||
func (s *Store) GenerateHeuristicSuggestions(ctx context.Context, documentID, tenantID int64, requestedBy *int64) (*MetadataSuggestion, error) {
|
||||
doc, err := s.GetDocument(ctx, documentID, tenantID)
|
||||
if err != nil {
|
||||
return nil, err // ErrDocumentNotFound propagates
|
||||
}
|
||||
|
||||
haystack := doc.Title
|
||||
if doc.OCRText != "" {
|
||||
haystack = doc.Title + "\n" + doc.OCRText
|
||||
}
|
||||
|
||||
// Entities already assigned to the document — excluded from suggestions.
|
||||
assignedTags := map[int64]bool{}
|
||||
tags, err := s.ListDocumentTags(ctx, documentID, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range tags {
|
||||
assignedTags[t.ID] = true
|
||||
}
|
||||
|
||||
tagCands, err := s.scoreCandidates(ctx, "tags", tenantID, haystack, func(id int64) bool { return assignedTags[id] })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
docTypeCands, err := s.scoreCandidates(ctx, "document_types", tenantID, haystack, func(id int64) bool {
|
||||
return doc.DocTypeID != nil && *doc.DocTypeID == id
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
corrCands, err := s.scoreCandidates(ctx, "correspondents", tenantID, haystack, func(id int64) bool {
|
||||
return doc.CorrespondentID != nil && *doc.CorrespondentID == id
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload := SuggestionPayload{
|
||||
DocTypeCandidates: docTypeCands,
|
||||
CorrespondentCandidates: corrCands,
|
||||
TagCandidates: tagCands,
|
||||
}
|
||||
if autoGeneratedTitlePattern.MatchString(strings.TrimSpace(doc.Title)) {
|
||||
if t := heuristicTitle(doc.OCRText); t != "" && t != doc.Title {
|
||||
payload.Title = &t
|
||||
}
|
||||
}
|
||||
// Belegdatum suggestion: only when the document has no document_date yet and
|
||||
// a plausible date is recognisable in the OCR text. Surfaced as a chip the
|
||||
// user can apply via PUT /api/documents/{id}/document-date. The confidence is
|
||||
// now derived from keyword-proximity scoring (see documentDateFromTextWithScore)
|
||||
// instead of a fixed value.
|
||||
if doc.DocumentDate == nil {
|
||||
if d, sc, ok := documentDateFromTextWithScore(doc.OCRText); ok {
|
||||
payload.DocumentDateCandidate = &DocumentDateCandidate{
|
||||
Date: d.Format("2006-01-02"),
|
||||
Score: sc,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: marshal suggestion payload: %w", err)
|
||||
}
|
||||
|
||||
row := s.db.QueryRow(ctx, `
|
||||
INSERT INTO metadata_suggestions (tenant_id, document_id, provider, requested_by, suggestion)
|
||||
VALUES ($1, $2, 'heuristic', $3, $4)
|
||||
RETURNING `+metadataSuggestionCols,
|
||||
tenantID, documentID, requestedBy, raw)
|
||||
m, err := scanMetadataSuggestion(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: insert metadata suggestion: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// scoreCandidates fuzzy-scores every entity of a kind for a tenant against the
|
||||
// haystack, keeps those at/above suggestionFloor that are not excluded (already
|
||||
// assigned), sorts by score descending and caps at maxSuggestionCandidates.
|
||||
func (s *Store) scoreCandidates(ctx context.Context, kind string, tenantID int64, haystack string, excluded func(id int64) bool) ([]SuggestionCandidate, error) {
|
||||
entities, err := s.ListTaxonomyEntities(ctx, kind, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SuggestionCandidate, 0)
|
||||
for _, e := range entities {
|
||||
if excluded(e.ID) {
|
||||
continue
|
||||
}
|
||||
score := matching.FuzzyScore(e.Name, e.CaseSensitive, haystack)
|
||||
if e.MatchPattern != "" {
|
||||
if p := matching.FuzzyScore(e.MatchPattern, e.CaseSensitive, haystack); p > score {
|
||||
score = p
|
||||
}
|
||||
}
|
||||
if score < suggestionFloor {
|
||||
continue
|
||||
}
|
||||
out = append(out, SuggestionCandidate{ID: e.ID, Name: e.Name, Score: score})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score })
|
||||
if len(out) > maxSuggestionCandidates {
|
||||
out = out[:maxSuggestionCandidates]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// heuristicTitle re-derives a candidate title from OCR text using a different
|
||||
// heuristic than titleFromOCRText (which takes the first meaningful line): it
|
||||
// picks the longest trimmed line, which is more likely to be a real heading /
|
||||
// company line than a short letterhead fragment or page number. Returns "" if
|
||||
// no line qualifies. Kept simple on purpose (no NLP).
|
||||
func heuristicTitle(ocrText string) string {
|
||||
if ocrText == "" {
|
||||
return ""
|
||||
}
|
||||
best := ""
|
||||
bestLen := 0
|
||||
for _, line := range strings.Split(ocrText, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
r := []rune(line)
|
||||
if len(r) < 5 {
|
||||
continue
|
||||
}
|
||||
if len(r) > bestLen {
|
||||
bestLen = len(r)
|
||||
if len(r) > 120 {
|
||||
line = string(r[:120])
|
||||
}
|
||||
best = line
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// GetLatestSuggestion returns the most recent metadata_suggestions row for a
|
||||
// document, scoped to tenant ownership, or ErrSuggestionNotFound if none exist.
|
||||
func (s *Store) GetLatestSuggestion(ctx context.Context, documentID, tenantID int64) (*MetadataSuggestion, error) {
|
||||
row := s.db.QueryRow(ctx, `SELECT `+metadataSuggestionCols+`
|
||||
FROM metadata_suggestions
|
||||
WHERE document_id = $1 AND tenant_id = $2
|
||||
ORDER BY requested_at DESC, id DESC
|
||||
LIMIT 1`, documentID, tenantID)
|
||||
m, err := scanMetadataSuggestion(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrSuggestionNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("storage: get latest metadata suggestion: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// MarkSuggestionReviewed flags a suggestion row as reviewed (the user has acted
|
||||
// on it in the UI, regardless of which fields they accepted — those went
|
||||
// through the normal edit endpoints). Scoped to tenant ownership. Returns
|
||||
// ErrSuggestionNotFound if the id/tenant scope does not match.
|
||||
func (s *Store) MarkSuggestionReviewed(ctx context.Context, id, tenantID, reviewedBy int64) error {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE metadata_suggestions
|
||||
SET status = 'reviewed', reviewed_at = now(), reviewed_by = $1
|
||||
WHERE id = $2 AND tenant_id = $3`, reviewedBy, id, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: mark metadata suggestion reviewed: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrSuggestionNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"archivdms/internal/classifier"
|
||||
)
|
||||
|
||||
// GenerateNaiveBayesSuggestions builds a metadata suggestion for a document
|
||||
// using the trained Naive-Bayes model (internal/classifier) instead of the
|
||||
// fuzzy-name heuristic or an LLM. It classifies the document's title+OCR text
|
||||
// against the tenant's trained document_types / correspondents / tags models,
|
||||
// maps the predicted class IDs back to taxonomy entities, drops entities that
|
||||
// are already assigned, and persists the result as a metadata_suggestions row
|
||||
// with provider='naive_bayes' — in the SAME SuggestionPayload schema the other
|
||||
// providers produce, so the API/frontend are unchanged.
|
||||
//
|
||||
// A kind whose model is untrained (or below the per-class data threshold) simply
|
||||
// yields no candidates for that kind — not an error. Any real failure (DB error,
|
||||
// classifier error) is returned as-is: there is NO silent fallback to the
|
||||
// heuristic provider (GoBD-Nachvollziehbarkeit — the caller reports which
|
||||
// provider produced or failed the run). requestedBy may be nil for
|
||||
// non-interactive callers.
|
||||
func (s *Store) GenerateNaiveBayesSuggestions(ctx context.Context, documentID, tenantID int64, requestedBy *int64) (*MetadataSuggestion, error) {
|
||||
doc, err := s.GetDocument(ctx, documentID, tenantID)
|
||||
if err != nil {
|
||||
return nil, err // ErrDocumentNotFound propagates
|
||||
}
|
||||
|
||||
text := doc.Title
|
||||
if doc.OCRText != "" {
|
||||
text = doc.Title + "\n" + doc.OCRText
|
||||
}
|
||||
|
||||
clf := classifier.New(s.db)
|
||||
|
||||
// Entities already assigned are excluded from suggestions, matching the
|
||||
// other providers' behaviour.
|
||||
assignedTags := map[int64]bool{}
|
||||
docTags, err := s.ListDocumentTags(ctx, documentID, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range docTags {
|
||||
assignedTags[t.ID] = true
|
||||
}
|
||||
|
||||
docTypeCands, err := s.naiveBayesCandidates(ctx, clf, "document_types", tenantID, text, func(id int64) bool {
|
||||
return doc.DocTypeID != nil && *doc.DocTypeID == id
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
corrCands, err := s.naiveBayesCandidates(ctx, clf, "correspondents", tenantID, text, func(id int64) bool {
|
||||
return doc.CorrespondentID != nil && *doc.CorrespondentID == id
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagCands, err := s.naiveBayesCandidates(ctx, clf, "tags", tenantID, text, func(id int64) bool {
|
||||
return assignedTags[id]
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload := SuggestionPayload{
|
||||
DocTypeCandidates: docTypeCands,
|
||||
CorrespondentCandidates: corrCands,
|
||||
TagCandidates: tagCands,
|
||||
}
|
||||
// The Naive-Bayes model does not propose a title (it classifies against
|
||||
// existing entities only); Title stays nil.
|
||||
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: marshal naive_bayes suggestion payload: %w", err)
|
||||
}
|
||||
|
||||
row := s.db.QueryRow(ctx, `
|
||||
INSERT INTO metadata_suggestions (tenant_id, document_id, provider, requested_by, suggestion)
|
||||
VALUES ($1, $2, 'naive_bayes', $3, $4)
|
||||
RETURNING `+metadataSuggestionCols,
|
||||
tenantID, documentID, requestedBy, raw)
|
||||
m, err := scanMetadataSuggestion(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: insert naive_bayes metadata suggestion: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// naiveBayesCandidates runs the classifier for one kind and maps predicted class
|
||||
// IDs back to SuggestionCandidate (resolving the entity name from the taxonomy),
|
||||
// dropping excluded (already-assigned) entities and any predicted ID that no
|
||||
// longer exists as a live entity. Result is always non-nil.
|
||||
func (s *Store) naiveBayesCandidates(ctx context.Context, clf *classifier.Classifier, kind string, tenantID int64, text string, excluded func(id int64) bool) ([]SuggestionCandidate, error) {
|
||||
preds, err := clf.Predict(ctx, tenantID, kind, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: naive_bayes predict %s: %w", kind, err)
|
||||
}
|
||||
if len(preds) == 0 {
|
||||
return make([]SuggestionCandidate, 0), nil
|
||||
}
|
||||
|
||||
entities, err := s.ListTaxonomyEntities(ctx, kind, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make(map[int64]string, len(entities))
|
||||
for _, e := range entities {
|
||||
names[e.ID] = e.Name
|
||||
}
|
||||
|
||||
out := make([]SuggestionCandidate, 0, len(preds))
|
||||
for _, p := range preds {
|
||||
if excluded(p.EntityID) {
|
||||
continue
|
||||
}
|
||||
name, ok := names[p.EntityID]
|
||||
if !ok {
|
||||
continue // predicted a class whose entity was deleted since training
|
||||
}
|
||||
out = append(out, SuggestionCandidate{ID: p.EntityID, Name: name, Score: p.Score, Explanation: p.TopTokens})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"archivdms/internal/llm"
|
||||
"archivdms/internal/matching"
|
||||
)
|
||||
|
||||
// maxOllamaOCRChars caps how much OCR text is fed into the prompt. The target
|
||||
// model is small (qwen2.5:1.5b, ~4GB-RAM server) with a limited context window,
|
||||
// so the first chunk of the document plus the taxonomy lists is all it sees.
|
||||
const maxOllamaOCRChars = 2000
|
||||
|
||||
// ollamaNameMatchFloor is the minimum fuzzy score at which an LLM-returned name
|
||||
// is accepted as referring to an existing taxonomy entity. The LLM only knows
|
||||
// names, never IDs, so its answers are mapped back to entities by name; anything
|
||||
// below this is treated as a hallucinated / non-existent entity and dropped.
|
||||
const ollamaNameMatchFloor = 0.8
|
||||
|
||||
// ollamaSuggestionResponse is the JSON schema the model is asked to fill. It
|
||||
// deliberately uses plain name lists (not the ID-bearing SuggestionCandidate
|
||||
// shape) because the LLM has no knowledge of internal IDs — names are mapped
|
||||
// back to entities afterwards.
|
||||
type ollamaSuggestionResponse struct {
|
||||
Title string `json:"title"`
|
||||
DocTypes []string `json:"doc_types"`
|
||||
Correspondents []string `json:"correspondents"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// GenerateOllamaSuggestions asks an EXTERNAL Ollama server (per-tenant config)
|
||||
// to propose metadata for a document and persists the result as a
|
||||
// metadata_suggestions row with provider='ollama', in the SAME SuggestionPayload
|
||||
// schema the heuristic provider produces (so the API/frontend are unchanged).
|
||||
//
|
||||
// The prompt contains the document title, a truncated slice of its OCR text and
|
||||
// the tenant's existing tags/document_types/correspondents (by name) so the
|
||||
// model reuses known entities. The model returns names; those are mapped back to
|
||||
// entity IDs by exact-then-fuzzy name match. Any Ollama error (unreachable,
|
||||
// timeout, invalid JSON) is returned as-is — NO silent fallback to heuristic
|
||||
// (GoBD-Nachvollziehbarkeit: the caller reports which provider failed).
|
||||
func (s *Store) GenerateOllamaSuggestions(ctx context.Context, documentID, tenantID int64, requestedBy *int64, cfg OllamaConfig) (*MetadataSuggestion, error) {
|
||||
if !cfg.Enabled {
|
||||
return nil, fmt.Errorf("storage: ollama provider not enabled for tenant")
|
||||
}
|
||||
|
||||
doc, err := s.GetDocument(ctx, documentID, tenantID)
|
||||
if err != nil {
|
||||
return nil, err // ErrDocumentNotFound propagates
|
||||
}
|
||||
|
||||
tags, err := s.ListTaxonomyEntities(ctx, "tags", tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
docTypes, err := s.ListTaxonomyEntities(ctx, "document_types", tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
correspondents, err := s.ListTaxonomyEntities(ctx, "correspondents", tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prompt := buildOllamaPrompt(doc, tags, docTypes, correspondents)
|
||||
|
||||
raw, err := llm.GenerateJSON(ctx, cfg.BaseURL, cfg.Model, time.Duration(cfg.TimeoutSeconds)*time.Second, prompt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: ollama generate: %w", err)
|
||||
}
|
||||
|
||||
var parsed ollamaSuggestionResponse
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("storage: ollama response does not match expected schema: %w", err)
|
||||
}
|
||||
|
||||
// Entities already assigned are excluded from the suggestions, matching the
|
||||
// heuristic provider's behaviour.
|
||||
assignedTags := map[int64]bool{}
|
||||
docTags, err := s.ListDocumentTags(ctx, documentID, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range docTags {
|
||||
assignedTags[t.ID] = true
|
||||
}
|
||||
|
||||
payload := SuggestionPayload{
|
||||
DocTypeCandidates: mapNamesToCandidates(parsed.DocTypes, docTypes, func(id int64) bool {
|
||||
return doc.DocTypeID != nil && *doc.DocTypeID == id
|
||||
}),
|
||||
CorrespondentCandidates: mapNamesToCandidates(parsed.Correspondents, correspondents, func(id int64) bool {
|
||||
return doc.CorrespondentID != nil && *doc.CorrespondentID == id
|
||||
}),
|
||||
TagCandidates: mapNamesToCandidates(parsed.Tags, tags, func(id int64) bool { return assignedTags[id] }),
|
||||
}
|
||||
if t := strings.TrimSpace(parsed.Title); t != "" && t != doc.Title {
|
||||
payload.Title = &t
|
||||
}
|
||||
|
||||
rawPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: marshal ollama suggestion payload: %w", err)
|
||||
}
|
||||
|
||||
row := s.db.QueryRow(ctx, `
|
||||
INSERT INTO metadata_suggestions (tenant_id, document_id, provider, requested_by, suggestion)
|
||||
VALUES ($1, $2, 'ollama', $3, $4)
|
||||
RETURNING `+metadataSuggestionCols,
|
||||
tenantID, documentID, requestedBy, rawPayload)
|
||||
m, err := scanMetadataSuggestion(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: insert ollama metadata suggestion: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// buildOllamaPrompt assembles a strict, schema-forcing prompt. Small models
|
||||
// need the format spelled out explicitly and benefit from being told to only
|
||||
// pick from the provided lists.
|
||||
func buildOllamaPrompt(doc *Document, tags, docTypes, correspondents []TaxonomyEntity) string {
|
||||
ocr := doc.OCRText
|
||||
if r := []rune(ocr); len(r) > maxOllamaOCRChars {
|
||||
ocr = string(r[:maxOllamaOCRChars])
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("Du bist ein Assistent für ein Dokumentenmanagement-System. ")
|
||||
b.WriteString("Analysiere das folgende Dokument und schlage passende Metadaten vor. ")
|
||||
b.WriteString("Antworte AUSSCHLIESSLICH mit einem JSON-Objekt in genau diesem Schema, ohne weiteren Text:\n")
|
||||
b.WriteString(`{"title": string, "doc_types": [string], "correspondents": [string], "tags": [string]}` + "\n\n")
|
||||
b.WriteString("Regeln:\n")
|
||||
b.WriteString("- Wähle doc_types, correspondents und tags NUR aus den unten aufgelisteten vorhandenen Werten (exakte Schreibweise).\n")
|
||||
b.WriteString("- Wenn nichts passt, gib eine leere Liste zurück.\n")
|
||||
b.WriteString("- title ist ein kurzer, aussagekräftiger Titel für das Dokument.\n\n")
|
||||
|
||||
b.WriteString("Vorhandene document_types: ")
|
||||
b.WriteString(joinEntityNames(docTypes))
|
||||
b.WriteString("\nVorhandene correspondents: ")
|
||||
b.WriteString(joinEntityNames(correspondents))
|
||||
b.WriteString("\nVorhandene tags: ")
|
||||
b.WriteString(joinEntityNames(tags))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString("Aktueller Titel: ")
|
||||
b.WriteString(doc.Title)
|
||||
b.WriteString("\n\nDokumenttext (Auszug):\n")
|
||||
b.WriteString(ocr)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// joinEntityNames renders entity names as a comma-separated list, or "(keine)"
|
||||
// when the tenant has no entities of that kind, so the prompt is never empty.
|
||||
func joinEntityNames(entities []TaxonomyEntity) string {
|
||||
if len(entities) == 0 {
|
||||
return "(keine)"
|
||||
}
|
||||
names := make([]string, 0, len(entities))
|
||||
for _, e := range entities {
|
||||
names = append(names, e.Name)
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// mapNamesToCandidates resolves LLM-returned names to existing taxonomy
|
||||
// entities by exact (case-insensitive) then fuzzy name match, dropping names
|
||||
// that match nothing above ollamaNameMatchFloor, that are already assigned
|
||||
// (excluded), or that duplicate an already-mapped entity. The Score reflects
|
||||
// the name-match confidence. Result is always non-nil, sorted by score desc,
|
||||
// capped at maxSuggestionCandidates.
|
||||
func mapNamesToCandidates(names []string, entities []TaxonomyEntity, excluded func(id int64) bool) []SuggestionCandidate {
|
||||
out := make([]SuggestionCandidate, 0)
|
||||
seen := map[int64]bool{}
|
||||
for _, raw := range names {
|
||||
name := strings.TrimSpace(raw)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
best := TaxonomyEntity{}
|
||||
bestScore := 0.0
|
||||
found := false
|
||||
for _, e := range entities {
|
||||
var score float64
|
||||
if strings.EqualFold(strings.TrimSpace(e.Name), name) {
|
||||
score = 1.0
|
||||
} else {
|
||||
score = matching.FuzzyScore(e.Name, false, name)
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
best = e
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found || bestScore < ollamaNameMatchFloor {
|
||||
continue
|
||||
}
|
||||
if excluded(best.ID) || seen[best.ID] {
|
||||
continue
|
||||
}
|
||||
seen[best.ID] = true
|
||||
out = append(out, SuggestionCandidate{ID: best.ID, Name: best.Name, Score: bestScore})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score })
|
||||
if len(out) > maxSuggestionCandidates {
|
||||
out = out[:maxSuggestionCandidates]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Dokumentation, siehe README.md. Wird zur Laufzeit idempotent von
|
||||
-- internal/tenantstore, internal/userstore, internal/storage und
|
||||
-- internal/audit initSchema()-Funktionen erzeugt.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||
domain VARCHAR(255),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tenants_domain ON tenants (domain) WHERE domain IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL DEFAULT '',
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_login_at TIMESTAMPTZ,
|
||||
tenant_id BIGINT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users (tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token_blacklist (
|
||||
jti VARCHAR(255) PRIMARY KEY,
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
doc_type TEXT,
|
||||
correspondent TEXT,
|
||||
storage_path TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
ocr_text TEXT,
|
||||
retain_until DATE,
|
||||
source TEXT, -- z.B. 'upload' | 'archivmail_import' (kein Importer implementiert)
|
||||
source_ref TEXT, -- externe Referenz (z.B. archivmail Mail-ID via API-Pull)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_tenant ON documents(tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
username VARCHAR(255) NOT NULL DEFAULT '',
|
||||
ip_address VARCHAR(45) NOT NULL DEFAULT '',
|
||||
document_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
success BOOLEAN NOT NULL DEFAULT true,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
tenant_id BIGINT
|
||||
);
|
||||
-- append-only: BEFORE UPDATE OR DELETE trigger raises, see internal/audit/audit.go
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Dokumentation, siehe README.md. Wird zur Laufzeit idempotent von
|
||||
-- internal/storage/reminders.go initReminderSchema() erzeugt.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reminders (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
document_id BIGINT NOT NULL REFERENCES documents(id),
|
||||
tenant_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
due_date TIMESTAMPTZ NOT NULL,
|
||||
note TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'open', -- open|done|dismissed
|
||||
notified_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reminders_tenant_due ON reminders(tenant_id, due_date) WHERE status = 'open';
|
||||
CREATE INDEX IF NOT EXISTS idx_reminders_document ON reminders(document_id);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Dokumentation, siehe README.md. Wird zur Laufzeit idempotent von
|
||||
-- internal/storage/documents.go initSchema() erzeugt.
|
||||
|
||||
-- Zusätzlicher Duplikatschutz auf DB-Ebene (neben dem Kollisionscheck auf
|
||||
-- Dateisystemebene im Upload-Handler, siehe internal/api/document_handlers.go
|
||||
-- handleUploadDocument): derselbe Mandant darf denselben Dateiinhalt
|
||||
-- (content_hash) nicht zweimal als aktives Dokument anlegen.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_tenant_hash ON documents(tenant_id, content_hash);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Dokumentation, siehe README.md. Wird zur Laufzeit idempotent von
|
||||
-- internal/storage/sftp_credentials.go initSFTPCredentialsSchema() erzeugt.
|
||||
|
||||
-- Per-Mandant-Zugangsdaten fuer den eingebetteten SFTP-Server
|
||||
-- (internal/sftpserver). Bewusst getrennt von `users`: ein SFTP-Zugang ist
|
||||
-- ein eigenstaendiges, jederzeit widerrufbares Credential, kein volles
|
||||
-- Login-Konto.
|
||||
CREATE TABLE IF NOT EXISTS sftp_credentials (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL, -- bcrypt, analog users.password_hash
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sftp_credentials_tenant ON sftp_credentials(tenant_id);
|
||||
@@ -0,0 +1,68 @@
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über internal/storage/taxonomy.go
|
||||
-- initTaxonomySchema(), aufgerufen aus (*Store).initSchema().
|
||||
--
|
||||
-- Strukturierte Entitäten (Tags/Dokumenttypen/Korrespondenten) statt der
|
||||
-- bisherigen documents.doc_type/correspondent-Freitextfelder (die bleiben
|
||||
-- unangetastet, Bestandsschutz), plus Barcode-Erkennung fuer automatische
|
||||
-- Zuordnung beim Ingest (siehe internal/matching, internal/barcode).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
match_algorithm TEXT NOT NULL DEFAULT 'none' CHECK (match_algorithm IN ('none','any','all','exact','regex','fuzzy')),
|
||||
match_pattern TEXT,
|
||||
case_sensitive BOOLEAN NOT NULL DEFAULT false,
|
||||
barcode_value TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_types (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
match_algorithm TEXT NOT NULL DEFAULT 'none' CHECK (match_algorithm IN ('none','any','all','exact','regex','fuzzy')),
|
||||
match_pattern TEXT,
|
||||
case_sensitive BOOLEAN NOT NULL DEFAULT false,
|
||||
barcode_value TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS correspondents (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
match_algorithm TEXT NOT NULL DEFAULT 'none' CHECK (match_algorithm IN ('none','any','all','exact','regex','fuzzy')),
|
||||
match_pattern TEXT,
|
||||
case_sensitive BOOLEAN NOT NULL DEFAULT false,
|
||||
barcode_value TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_tags (
|
||||
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (document_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tags_tenant ON tags(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_types_tenant ON document_types(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_correspondents_tenant ON correspondents(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_tags_tag ON document_tags(tag_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_tenant_barcode ON tags(tenant_id, barcode_value) WHERE barcode_value IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_document_types_tenant_barcode ON document_types(tenant_id, barcode_value) WHERE barcode_value IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_correspondents_tenant_barcode ON correspondents(tenant_id, barcode_value) WHERE barcode_value IS NOT NULL;
|
||||
|
||||
-- documents: neue Spalten, alte doc_type/correspondent-Textspalten bleiben
|
||||
-- unveraendert (Bestandsschutz fuer vorhandene GoBD-Metadaten).
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS doc_type_id BIGINT REFERENCES document_types(id);
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS correspondent_id BIGINT REFERENCES correspondents(id);
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS barcode_values JSONB;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- PROJ: custom-fields
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über internal/storage/custom_fields.go
|
||||
-- initCustomFieldsSchema(), aufgerufen aus (*Store).initSchema().
|
||||
--
|
||||
-- Benutzerdefinierte Felder (Custom Fields): tenant-skopierte Feld-
|
||||
-- Definitionen, Zuordnung pro Dokumenttyp (required/visible/sort_order) und
|
||||
-- die eigentlichen Werte pro Dokument. Regeln: enum-Wert liegt in value_text,
|
||||
-- monetary in value_number (NUMERIC(14,2) semantisch).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS custom_field_defs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
field_type TEXT NOT NULL CHECK (field_type IN ('text','number','date','boolean','enum','monetary')),
|
||||
enum_options JSONB,
|
||||
currency TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_type_fields (
|
||||
doc_type_id BIGINT NOT NULL REFERENCES document_types(id) ON DELETE CASCADE,
|
||||
field_id BIGINT NOT NULL REFERENCES custom_field_defs(id) ON DELETE CASCADE,
|
||||
required BOOLEAN NOT NULL DEFAULT false,
|
||||
visible BOOLEAN NOT NULL DEFAULT true,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (doc_type_id, field_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_field_values (
|
||||
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
field_id BIGINT NOT NULL REFERENCES custom_field_defs(id) ON DELETE CASCADE,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
value_text TEXT,
|
||||
value_number NUMERIC,
|
||||
value_date DATE,
|
||||
value_bool BOOLEAN,
|
||||
PRIMARY KEY (document_id, field_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dfv_tenant_field ON document_field_values(tenant_id, field_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dfv_field_text ON document_field_values(field_id, value_text);
|
||||
CREATE INDEX IF NOT EXISTS idx_dfv_field_number ON document_field_values(field_id, value_number);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- PROJ: trash-staged-deletion
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über internal/storage/trash.go
|
||||
-- initTrashSchema(), aufgerufen aus (*Store).initSchema().
|
||||
--
|
||||
-- Papierkorb + gestaffeltes Löschkonzept (GoBD):
|
||||
-- * documents.deleted_at/deleted_by = Soft-Delete (Papierkorb). Die WORM-Datei
|
||||
-- bleibt physisch unangetastet (chmod 0440) bis ein Löschantrag den Status
|
||||
-- 'executed' erreicht.
|
||||
-- * document_delete_requests = Vier-/Zwei-Augen-Workflow für finales Löschen:
|
||||
-- User A stellt Antrag ('pending'), ein anderer domain_admin (User B)
|
||||
-- bestätigt ('confirmed'->'executed'). Retention (retain_until) wird bei
|
||||
-- Antrag UND Bestätigung geprüft; ein blockierter Versuch wird als
|
||||
-- 'blocked_retention' protokolliert (Nachvollziehbarkeit).
|
||||
-- * Finales Löschen: physische Datei via os.Remove entfernt, DB-Row als
|
||||
-- Tombstone behalten (storage_path/ocr_text geleert, content_hash bleibt).
|
||||
-- * Das Vier-Augen-Prinzip (confirmed_by != requested_by) wird im Go-Store
|
||||
-- erzwungen, NICHT per CHECK-Constraint (confirmed_by wird erst später
|
||||
-- gesetzt).
|
||||
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS deleted_by BIGINT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_delete_requests (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
requested_by BIGINT NOT NULL,
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
confirmed_by BIGINT,
|
||||
confirmed_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','confirmed','executed','cancelled','blocked_retention')),
|
||||
UNIQUE(document_id, status)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ddr_tenant_status ON document_delete_requests(tenant_id, status);
|
||||
@@ -0,0 +1,78 @@
|
||||
-- PROJ: permission-model-group-acl
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über internal/storage/permissions.go
|
||||
-- initPermissionsSchema(), aufgerufen aus (*Store).New().
|
||||
--
|
||||
-- Berechtigungsmodell (gruppenaufgelöste, geschichtete Dokument-ACL):
|
||||
-- Zugriff wird NIE direkt pro User vergeben, sondern über permission_groups.
|
||||
-- Auflösung über drei Ebenen, spezifischer schlägt allgemeiner:
|
||||
-- 1. document_grants (pro Dokument, 'deny' entfernt eine Gruppe komplett)
|
||||
-- 2. tag_grants (über die Tags des Dokuments)
|
||||
-- 3. document_type_grants (über documents.doc_type_id)
|
||||
-- Ergebnis wird von RecomputeVisibility() nach document_visibility
|
||||
-- materialisiert (DELETE+INSERT je Dokument in einer Transaktion).
|
||||
--
|
||||
-- Rollen bleiben Außengrenze: superadmin sieht alles tenant-übergreifend,
|
||||
-- domain_admin sieht per Default alles im eigenen Tenant (bypasst ACL), nur
|
||||
-- Rolle 'user' wird in ListDocuments gegen document_visibility gefiltert.
|
||||
--
|
||||
-- RecomputeVisibility MUSS neu laufen, wenn sich Grants, die Tags eines
|
||||
-- Dokuments (AttachTag/DetachTag) oder der Dokumenttyp (SetDocumentDocType)
|
||||
-- ändern — diese Aufrufe sind im Go-Code angehängt.
|
||||
|
||||
-- Hinweis: tenant_id / user_id / granted_by tragen bewusst KEINE FK auf
|
||||
-- tenants(id) bzw. users(id) — konsistent mit documents/taxonomy (plain BIGINT
|
||||
-- tenant_id) und weil tenants/users von anderen Stores NACH storage.New()
|
||||
-- angelegt werden (Reihenfolge in cmd/archivdms/main.go). Tenant-Ownership wird
|
||||
-- applikationsseitig geprüft (permissions.go).
|
||||
CREATE TABLE IF NOT EXISTS permission_groups (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permission_group_members (
|
||||
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (group_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_type_grants (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
doc_type_id BIGINT NOT NULL REFERENCES document_types(id) ON DELETE CASCADE,
|
||||
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
||||
access TEXT NOT NULL DEFAULT 'read' CHECK (access IN ('read','write')),
|
||||
UNIQUE (doc_type_id, group_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tag_grants (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
||||
access TEXT NOT NULL DEFAULT 'read' CHECK (access IN ('read','write')),
|
||||
UNIQUE (tag_id, group_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_grants (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
||||
access TEXT NOT NULL CHECK (access IN ('read','write','deny')),
|
||||
granted_by BIGINT NOT NULL,
|
||||
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (document_id, group_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_visibility (
|
||||
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
||||
access TEXT NOT NULL CHECK (access IN ('read','write')),
|
||||
PRIMARY KEY (document_id, group_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_visibility_group ON document_visibility(group_id, document_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pgm_user ON permission_group_members(user_id, group_id);
|
||||
@@ -0,0 +1,53 @@
|
||||
-- PROJ: external-share-links
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über internal/storage/shares.go
|
||||
-- initSharesSchema(), aufgerufen aus (*Store).New().
|
||||
--
|
||||
-- Externe Share-Links: tenant-scoped, ablaufender, optional passwortgeschützter
|
||||
-- öffentlicher Link auf genau EIN Dokument.
|
||||
-- * Token: 32 Byte crypto/rand, base64url; wird beim Erzeugen genau EINMAL
|
||||
-- zurückgegeben. Persistiert wird NUR der SHA-256-Hash (token_hash). Der
|
||||
-- öffentliche Abruf sucht immer über token_hash, nie über id.
|
||||
-- * expires_at ist Pflicht (kein unbegrenzter Share). password_hash optional
|
||||
-- (bcrypt, Cost 12).
|
||||
-- * Kein Hard-Delete: Revoke setzt nur revoked_at/revoked_by.
|
||||
-- * Prüfreihenfolge beim öffentlichen Abruf: revoked -> expired ->
|
||||
-- max_accesses erreicht -> Passwort -> ausliefern (access_count++ atomar
|
||||
-- mit WHERE-Guard gegen Race). Datei wird serverseitig aus dem WORM-Store
|
||||
-- gestreamt; storage_path/content_hash NIE im Response.
|
||||
-- * Jeder Zugriffsversuch (Erfolg/Fehlschlag) landet in
|
||||
-- document_share_accesses mit passendem result-Wert; die öffentlichen
|
||||
-- Endpunkte sind zusätzlich per-IP rate-limited (Token-Bucket in-memory).
|
||||
--
|
||||
-- Hinweis: tenant_id / created_by / revoked_by tragen bewusst KEINE FK auf
|
||||
-- tenants(id) bzw. users(id) — konsistent mit documents/taxonomy/permissions
|
||||
-- (plain BIGINT) und weil tenants/users von anderen Stores NACH storage.New()
|
||||
-- angelegt werden (Reihenfolge in cmd/archivdms/main.go). document_id behält
|
||||
-- seine FK, da documents die eigene Tabelle dieses Stores ist.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_shares (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_by BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
max_accesses INT,
|
||||
access_count INT NOT NULL DEFAULT 0,
|
||||
password_hash TEXT,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_by BIGINT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_shares_document ON document_shares(document_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_shares_tenant ON document_shares(tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_share_accesses (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
share_id BIGINT NOT NULL REFERENCES document_shares(id) ON DELETE CASCADE,
|
||||
accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
result TEXT NOT NULL CHECK (result IN ('success','expired','revoked','max_reached','bad_password','rate_limited'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_share_accesses_share ON document_share_accesses(share_id, accessed_at DESC);
|
||||
@@ -0,0 +1,42 @@
|
||||
-- PROJ: manticore-search-index-phase1
|
||||
-- Doku-only. KEINE PostgreSQL-Schema-Änderung in dieser Phase — Postgres
|
||||
-- (documents + Taxonomie + document_visibility) bleibt Source of Truth und
|
||||
-- unverändert. Diese Datei dokumentiert nur den externen Volltext-Index.
|
||||
--
|
||||
-- Phase 1 der geplanten Manticore-Search-Integration (Hybrid BM25+Vektor
|
||||
-- kommt später): NUR Schema + Sync-Layer, KEIN Such-Endpunkt.
|
||||
--
|
||||
-- Der Index läuft in Manticore Search (MySQL-Protokoll, Default Port 9306),
|
||||
-- angesprochen über internal/index (github.com/go-sql-driver/mysql, CGO-frei).
|
||||
-- Pro Mandant existiert eine RT-Tabelle documents_tenant_<tenant_id>, die
|
||||
-- idempotent von ensureTable() angelegt wird:
|
||||
--
|
||||
-- CREATE TABLE documents_tenant_N (
|
||||
-- doc_id string,
|
||||
-- title text,
|
||||
-- doc_type text,
|
||||
-- correspondent text,
|
||||
-- ocr_text text,
|
||||
-- tags text,
|
||||
-- tag_ids multi,
|
||||
-- doc_type_id bigint,
|
||||
-- correspondent_id bigint,
|
||||
-- acl_group_ids multi,
|
||||
-- retain_until_ts bigint,
|
||||
-- created_ts bigint,
|
||||
-- updated_ts bigint,
|
||||
-- deleted uint
|
||||
-- ) type='rt' morphology='lemmatize_de_all,stem_en'
|
||||
--
|
||||
-- Aktivierung nur wenn index.manticore_dsn in der config.yml gesetzt ist —
|
||||
-- sonst ist der Indexer nil und alle Sync-Aufrufe sind No-ops.
|
||||
--
|
||||
-- Sync-Punkte (alle best-effort, Fehler werden nur geloggt, blockieren nie den
|
||||
-- Haupt-Request — Postgres bleibt maßgeblich):
|
||||
-- * Dokument-Upload/Create -> IndexSync
|
||||
-- * RecomputeVisibility (ACL/Tags/DocType) -> IndexSync
|
||||
-- * SetDocumentCorrespondent -> IndexSync
|
||||
-- * Custom-Field-Werte setzen -> IndexSync
|
||||
-- * SoftDeleteDocument (Papierkorb) -> Delete (nicht mehr auffindbar)
|
||||
-- * RestoreDocument -> IndexSync (wieder auffindbar)
|
||||
-- * ConfirmDeleteRequest (final/executed) -> Delete (GoBD: endgültig weg)
|
||||
@@ -0,0 +1,52 @@
|
||||
-- PROJ: classification-templates
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über
|
||||
-- internal/storage/classification_templates.go
|
||||
-- initClassificationTemplatesSchema(), aufgerufen aus (*Store).initSchema()
|
||||
-- NACH initTaxonomySchema/initCustomFieldsSchema (FK auf document_types und
|
||||
-- custom_field_defs).
|
||||
--
|
||||
-- Klassifizierungsvorlagen (classification templates): tenant-skopierte,
|
||||
-- benannte Bündel aus Dokumenttyp, Tags, Custom-Field-Defaultwerten und einer
|
||||
-- Aufbewahrungsdauer, die in einem Schritt auf ein Dokument angewendet werden
|
||||
-- können. Bewusst KEINE persistente Kopplung template<->document (keine
|
||||
-- template_id-Spalte auf documents): eine spätere Vorlagen-Änderung darf
|
||||
-- Bestandsdokumente niemals rückwirkend verändern (GoBD-Nachvollziehbarkeit).
|
||||
-- Die Anwendung wird ausschliesslich über einen Audit-Log-Eintrag festgehalten.
|
||||
--
|
||||
-- Retention-Regel (absolut, ohne Ausnahme): eine Vorlage kann retain_until nur
|
||||
-- verlängern, niemals verkürzen — auch nicht mit overwrite=true. Ein
|
||||
-- abgelehnter Verkürzungsversuch wird als Audit-Eintrag protokolliert.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classification_templates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
doc_type_id BIGINT REFERENCES document_types(id) ON DELETE SET NULL,
|
||||
retain_years INT,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_by BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(tenant_id, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_templates_tenant ON classification_templates(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_templates_doc_type ON classification_templates(doc_type_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classification_template_tags (
|
||||
template_id BIGINT NOT NULL REFERENCES classification_templates(id) ON DELETE CASCADE,
|
||||
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (template_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classification_template_field_defaults (
|
||||
template_id BIGINT NOT NULL REFERENCES classification_templates(id) ON DELETE CASCADE,
|
||||
field_id BIGINT NOT NULL REFERENCES custom_field_defs(id) ON DELETE CASCADE,
|
||||
value_text TEXT,
|
||||
value_number NUMERIC,
|
||||
value_date DATE,
|
||||
value_bool BOOLEAN,
|
||||
overwrite BOOLEAN NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (template_id, field_id)
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
-- PROJ: heuristische Metadaten-Vorschläge
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über
|
||||
-- internal/storage/metadata_suggestions.go initMetadataSuggestionsSchema(),
|
||||
-- aufgerufen aus (*Store).initSchema() NACH dem documents-/taxonomy-Schema
|
||||
-- (die Vorschläge scoren Taxonomie-Entitäten gegen ein Dokument).
|
||||
--
|
||||
-- Metadaten-Vorschläge: regelbasiert (KEIN LLM). Pro Vorschlags-Lauf wird ein
|
||||
-- Dokument gegen alle Taxonomie-Entitäten (Tags/Dokumenttypen/Korrespondenten)
|
||||
-- fuzzy-gescored; Near-Misses oberhalb einer Schwelle (suggestionFloor), die
|
||||
-- noch NICHT zugewiesen sind, werden als nicht-bindende Kandidaten vorgeschlagen.
|
||||
-- Sieht der aktuelle Titel noch auto-generiert aus, wird zusätzlich ein
|
||||
-- neu abgeleiteter Titel vorgeschlagen.
|
||||
--
|
||||
-- Diese Tabelle ist NUR ein Log/Cache dessen, was der heuristische Provider
|
||||
-- vorgeschlagen hat. Das Anwenden eines akzeptierten Feldes läuft über die
|
||||
-- normalen Edit-Endpunkte (PATCH title, tag-attach, ...), NIEMALS über diese
|
||||
-- Zeile. status wird beim Review ('reviewed') gesetzt, unabhängig davon welche
|
||||
-- Felder der Nutzer übernommen hat.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_suggestions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
document_id BIGINT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'heuristic',
|
||||
requested_by BIGINT,
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
suggestion JSONB NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','reviewed')),
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
reviewed_by BIGINT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_metadata_suggestions_document ON metadata_suggestions(document_id);
|
||||
@@ -0,0 +1,57 @@
|
||||
-- PROJ: workflows / Consumption-Regeln
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über internal/storage/workflows.go
|
||||
-- initWorkflowsSchema(), aufgerufen aus (*Store).initSchema() NACH
|
||||
-- initClassificationTemplatesSchema (eine Workflow-Action kann eine
|
||||
-- Klassifizierungsvorlage referenzieren: apply_classification_template).
|
||||
--
|
||||
-- Workflows (Consumption-Regeln): tenant-skopierte Automatisierungsregeln, die
|
||||
-- an einem Trigger-Punkt (MVP: on_upload) ausgewertet werden. Ein passender
|
||||
-- Workflow führt seine geordneten Actions auf dem Dokument aus. Die
|
||||
-- Bedingung ist ein JSONB-Condition-Tree (bool. Gruppen and/or + Leaf-Knoten
|
||||
-- mit Feld/Algorithmus/Pattern, MVP-Verschachtelungstiefe 2). Jede Auswertung
|
||||
-- wird dokument-skopiert in workflow_runs protokolliert (GoBD-Reproduzierbarkeit
|
||||
-- parallel zum globalen internal/audit-Log).
|
||||
--
|
||||
-- Best-effort-Ausführung: eine fehlgeschlagene Action bricht den Upload niemals
|
||||
-- ab — Fehler werden in workflow_runs.error festgehalten und die Schleife läuft
|
||||
-- weiter (spiegelt die "never fail the upload"-Philosophie der OCR-/
|
||||
-- Auto-Assign-Schritte in storeUploadedFile).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflows (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
trigger_type TEXT NOT NULL CHECK (trigger_type IN ('on_upload')),
|
||||
condition_tree JSONB NOT NULL,
|
||||
priority INT NOT NULL DEFAULT 100,
|
||||
created_by BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(tenant_id, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflows_tenant ON workflows(tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_actions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
workflow_id BIGINT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
|
||||
step_order INT NOT NULL,
|
||||
action_type TEXT NOT NULL CHECK (action_type IN
|
||||
('add_tag','set_doc_type','set_correspondent','apply_classification_template','set_custom_field')),
|
||||
action_config JSONB NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_actions_workflow ON workflow_actions(workflow_id, step_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_runs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
workflow_id BIGINT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
document_id BIGINT,
|
||||
triggered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
matched BOOLEAN NOT NULL,
|
||||
actions_applied JSONB,
|
||||
error TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow ON workflow_runs(workflow_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_document ON workflow_runs(document_id);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- PROJ: Freitext-Notizen pro Dokument (Paperless-ngx inspiriert)
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über
|
||||
-- internal/storage/document_notes.go initDocumentNotesSchema(), aufgerufen aus
|
||||
-- (*Store).initSchema() NACH dem documents-Schema (FK auf documents(id)).
|
||||
--
|
||||
-- Abgrenzung zu Custom-Fields: Custom-Fields sind strukturierte Metadaten;
|
||||
-- eine Notiz ist reiner Freitext-Kommentar mit Autor + Zeitstempel. Notizen
|
||||
-- sind KEINE GoBD-Belege, deshalb hartes DELETE (kein Soft-Delete). Create und
|
||||
-- Delete werden dennoch im Audit-Log protokolliert (Nachvollziehbarkeit).
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- PROJ: Gespeicherte Suchansichten (SavedViews, Paperless-ngx inspiriert)
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über
|
||||
-- internal/storage/saved_views.go initSavedViewsSchema(), aufgerufen aus
|
||||
-- (*Store).initSchema() NACH initDocumentNotesSchema.
|
||||
--
|
||||
-- Nutzer speichern ihre aktuelle Such-/Filter-Query als benannte,
|
||||
-- wiederverwendbare Ansicht. filters ist die serialisierte index.SearchQuery
|
||||
-- (JSONB, 1:1 wieder einlesbar). Eine Ansicht ist privat für ihren Ersteller,
|
||||
-- außer is_shared=true -> tenant-weit sichtbar, aber weiterhin nur vom
|
||||
-- Ersteller änderbar/löschbar (WHERE id + tenant_id + user_id). Create/Update/
|
||||
-- Delete werden im Audit-Log protokolliert (Nachvollziehbarkeit).
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- PROJ: Pro-Mandant konfigurierbares Datumsformat für Platzhalter-Titel
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über
|
||||
-- internal/tenantstore/store.go (*Store).initSchema().
|
||||
--
|
||||
-- Beim Upload wird, wenn kein Titel angegeben ist und OCR keine sinnvolle
|
||||
-- Überschrift liefert, ein Platzhalter-Titel "Scan <Datum>" erzeugt
|
||||
-- (titleFromOCRText in internal/api/document_handlers.go). Das Datumsformat
|
||||
-- war bisher hart auf DD.MM.YYYY HH:mm codiert. Diese Spalte macht es pro
|
||||
-- Mandant (nicht global, nicht pro Nutzer) konfigurierbar.
|
||||
--
|
||||
-- Gespeichert wird EINER von wenigen bekannten Format-Schlüsseln (kein roher
|
||||
-- Go-Layout-String vom Nutzer), das Backend mappt den Schlüssel auf das
|
||||
-- Go-Layout und validiert den Wert (400 bei Unbekannt). Erlaubte Schlüssel:
|
||||
-- 'DD.MM.YYYY HH:mm' -> 02.01.2006 15:04 (deutsch, Default)
|
||||
-- 'YYYY-MM-DD HH:mm' -> 2006-01-02 15:04 (ISO)
|
||||
-- 'MM/DD/YYYY hh:mm AM/PM' -> 01/02/2006 03:04 PM (US)
|
||||
|
||||
ALTER TABLE tenants
|
||||
ADD COLUMN IF NOT EXISTS scan_title_date_format TEXT NOT NULL DEFAULT 'DD.MM.YYYY HH:mm';
|
||||
@@ -0,0 +1,19 @@
|
||||
-- PROJ: Pro-Mandant konfigurierbares Präfix-Wort für Platzhalter-Titel
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über
|
||||
-- internal/tenantstore/store.go (*Store).initSchema().
|
||||
--
|
||||
-- Beim Upload wird, wenn kein Titel angegeben ist und OCR keine sinnvolle
|
||||
-- Überschrift liefert, ein Platzhalter-Titel "<Präfix> <Datum>" erzeugt
|
||||
-- (titleFromOCRText in internal/api/document_handlers.go). Das Präfix-Wort
|
||||
-- war bisher hart auf "Scan" codiert. Diese Spalte macht es pro Mandant
|
||||
-- (nicht global, nicht pro Nutzer) konfigurierbar, z.B. "Beleg", "Import",
|
||||
-- "Eingang".
|
||||
--
|
||||
-- Validierung im Backend (internal/tenantstore/store.go UpdateScanTitlePrefix):
|
||||
-- nicht leer, maximal 40 Zeichen (400 bei Verstoß). Ergänzt Migration 015
|
||||
-- (scan_title_date_format), beide Settings sind unabhängig änderbar über
|
||||
-- GET/PUT /api/tenant-settings.
|
||||
|
||||
ALTER TABLE tenants
|
||||
ADD COLUMN IF NOT EXISTS scan_title_prefix TEXT NOT NULL DEFAULT 'Scan';
|
||||
@@ -0,0 +1,26 @@
|
||||
-- PROJ: Pro-Mandant konfigurierbare Anbindung an einen EXTERNEN Ollama-Server
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über
|
||||
-- internal/storage/documents.go (*Store).initSchema() -> initOllamaConfigSchema
|
||||
-- (internal/storage/ollama_config.go). Source of Truth bleibt der Go-Code.
|
||||
--
|
||||
-- Ollama läuft NICHT lokal auf dem archivdms-Host, sondern als bereits
|
||||
-- laufender externer Dienst; IP/Port kommt vom Mandanten-Admin. Diese Tabelle
|
||||
-- speichert die Verbindung PRO Mandant (analog ldap_configs), nicht global.
|
||||
-- Sie schaltet den optionalen 'ollama'-Provider der Metadaten-Vorschläge frei
|
||||
-- (metadata_suggestions.provider), neben dem bestehenden 'heuristic'-Provider.
|
||||
--
|
||||
-- Validierung im Backend (UpsertOllamaConfig): bei enabled=true muss base_url
|
||||
-- (http:// oder https:// Präfix) und model gesetzt sein, timeout_seconds in
|
||||
-- [5,120]. base_url ist eine interne Netzwerk-URL, kein Secret, und wird von
|
||||
-- GET/PUT /api/ollama-config normal zurückgegeben (kein Masking wie bei der
|
||||
-- LDAP-Bind-Passwort-Spalte).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenant_ollama_config (
|
||||
tenant_id BIGINT PRIMARY KEY REFERENCES tenants(id),
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
timeout_seconds INT NOT NULL DEFAULT 30,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- PROJ: Digitale Akte (digitaler Aktenordner) — Gruppierung von Dokumenten.
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über
|
||||
-- internal/storage/documents.go (*Store).initSchema() -> initAktenSchema
|
||||
-- (internal/storage/akten.go). Source of Truth bleibt der Go-Code.
|
||||
--
|
||||
-- Strikt 1:n zu Dokumenten via documents.akte_id (kein Join-Table): ein
|
||||
-- Dokument gehört zu maximal einer Akte. ON DELETE SET NULL ist die
|
||||
-- GoBD-Absicherung — eine Akte löschen kann strukturell nie Dokumente löschen,
|
||||
-- sondern entkoppelt sie nur. Keine eigene ACL: die Sichtbarkeit einer Akte
|
||||
-- erbt von den enthaltenen Dokumenten (EXISTS gegen document_visibility,
|
||||
-- gleiches Pattern wie ListDocuments). Siehe project_akte_konzept_plan.md.
|
||||
--
|
||||
-- Reihenfolge: akten-Tabelle muss VOR der ALTER TABLE auf documents existieren,
|
||||
-- da documents.akte_id auf akten(id) verweist. correspondent_id verweist auf
|
||||
-- correspondents(id) (initTaxonomySchema läuft davor).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS akten (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
titel TEXT NOT NULL,
|
||||
beschreibung TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'offen' CHECK (status IN ('offen','geschlossen')),
|
||||
correspondent_id BIGINT REFERENCES correspondents(id),
|
||||
created_by BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
closed_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_akten_tenant ON akten (tenant_id);
|
||||
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS akte_id BIGINT REFERENCES akten(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- PROJ: Beleg-/Dokumentdatum (document_date) aus dem OCR-Text
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über
|
||||
-- internal/storage/documents.go (*Store).initSchema().
|
||||
--
|
||||
-- Bisher wurde der WORM-Ablagepfad store/<tenant>/<yyyy>/<mm>/<hash>.<ext>
|
||||
-- aus dem Scan-/Upload-Zeitpunkt (time.Now()) gebildet. Neu wird beim Upload
|
||||
-- versucht, das echte Belegdatum (Rechnungs-/Dokumentdatum) aus dem OCR-Text
|
||||
-- zu erkennen (extractDocumentDate in internal/api/date_extraction.go,
|
||||
-- Regex-basiert: DD.MM.YYYY, DD.MM.YY, YYYY-MM-DD). Wird ein plausibles Datum
|
||||
-- gefunden, bestimmt dessen Jahr/Monat den Ablagepfad; sonst Fallback auf den
|
||||
-- Scan-Zeitpunkt wie bisher.
|
||||
--
|
||||
-- created_at bleibt unverändert der unveränderliche Scan-/Upload-Zeitstempel
|
||||
-- (GoBD-Nachvollziehbarkeit). document_date ist ein SEPARATES, nullbares
|
||||
-- Metadatenfeld: NULL bei Altbeständen und wenn kein Datum erkannt wurde.
|
||||
--
|
||||
-- Reprocess (POST /api/documents/{id}/reprocess) aktualisiert bei erneuter
|
||||
-- OCR-Verarbeitung nur dieses Feld — die bereits WORM-gesperrte Datei und ihr
|
||||
-- Ablagepfad werden NIEMALS nachträglich verschoben.
|
||||
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN IF NOT EXISTS document_date DATE;
|
||||
@@ -0,0 +1,55 @@
|
||||
-- PROJ: ML-Retraining-Klassifizierung Phase 1 (Naive-Bayes)
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über
|
||||
-- internal/storage/ml_classifier.go (*Store).initMLClassifierSchema(),
|
||||
-- eingehängt in internal/storage/documents.go (*Store).initSchema() NACH
|
||||
-- initTaxonomySchema (setzt document_types/correspondents/tags/document_tags/
|
||||
-- documents voraus).
|
||||
--
|
||||
-- Ergänzt die bestehende Regel-Engine (Taxonomie-Matching) um ein optionales,
|
||||
-- pro Mandant trainiertes Naive-Bayes-Modell: aus bereits klassifizierten
|
||||
-- Dokumenten werden Token-Häufigkeiten je Klasse (document_type/correspondent/
|
||||
-- tag) gelernt (ml_classifier_tokens/ml_classifier_classes) und je Trainingslauf
|
||||
-- protokolliert (ml_classifier_runs).
|
||||
--
|
||||
-- Provenienz-Spalten (assigned_via auf document_tags, doc_type_assigned_via/
|
||||
-- correspondent_assigned_via auf documents) unterscheiden 'manual' (Nutzer
|
||||
-- hat gesetzt), 'rule' (Regel-Engine) und 'ml_accepted' (vom Klassifizierer
|
||||
-- vorgeschlagen und vom Nutzer akzeptiert). DEFAULT 'manual' bewusst konservativ
|
||||
-- gewählt: bestehende Zeilen werden NICHT rückwirkend als 'rule' fehlklassifiziert
|
||||
-- und fließen dadurch weiterhin normal als Trainingsdaten in den Klassifizierer ein.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ml_classifier_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('document_types','correspondents','tags')),
|
||||
entity_id BIGINT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
count BIGINT NOT NULL DEFAULT 0,
|
||||
UNIQUE (tenant_id, kind, entity_id, token)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ml_tokens_lookup ON ml_classifier_tokens(tenant_id, kind, token);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ml_classifier_classes (
|
||||
tenant_id BIGINT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
entity_id BIGINT NOT NULL,
|
||||
doc_count BIGINT NOT NULL DEFAULT 0,
|
||||
total_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (tenant_id, kind, entity_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ml_classifier_runs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
doc_count BIGINT NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running','completed','failed','skipped_insufficient_data')),
|
||||
error TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ml_classifier_runs_tenant ON ml_classifier_runs(tenant_id);
|
||||
|
||||
ALTER TABLE document_tags ADD COLUMN IF NOT EXISTS assigned_via TEXT NOT NULL DEFAULT 'manual' CHECK (assigned_via IN ('manual','rule','ml_accepted'));
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS doc_type_assigned_via TEXT NOT NULL DEFAULT 'manual' CHECK (doc_type_assigned_via IN ('manual','rule','ml_accepted'));
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS correspondent_assigned_via TEXT NOT NULL DEFAULT 'manual' CHECK (correspondent_assigned_via IN ('manual','rule','ml_accepted'));
|
||||
@@ -0,0 +1,31 @@
|
||||
-- PROJ: Titel-Vorlage für Klassifizierungsvorlagen + globaler Tenant-Default
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über die Go-initSchema-Aufrufe:
|
||||
-- * classification_templates.title_template ->
|
||||
-- internal/storage/classification_templates.go (*Store).initClassificationTemplatesSchema()
|
||||
-- * tenants.default_title_template ->
|
||||
-- internal/tenantstore/store.go (*Store).initSchema()
|
||||
--
|
||||
-- Beim Anwenden einer Klassifizierungsvorlage (POST /api/documents/{id}/apply-template
|
||||
-- ODER Workflow-Aktion apply_classification_template, gemeinsamer Code-Pfad in
|
||||
-- internal/storage/classification_templates_apply.go ApplyTemplate) wird der
|
||||
-- Dokumenttitel aus einer Go-text/template-Vorlage abgeleitet:
|
||||
-- 1. title_template der Vorlage (NULL = keine),
|
||||
-- 2. sonst tenant-weites default_title_template (NULL = keins),
|
||||
-- 3. sonst bleibt der bestehende Titel unangetastet.
|
||||
-- Gesetzt wird NUR wenn documents.title_manually_set = false; title_manually_set
|
||||
-- bleibt danach false (erneute Anwendung nach späterer Korrektur möglich). Bei
|
||||
-- leerem/fehlerhaftem Render-Ergebnis Fallback auf den bestehenden Titel, nie
|
||||
-- leerer String.
|
||||
--
|
||||
-- Platzhalter (Struct-Felder): {{.Correspondent}} {{.DocumentType}}
|
||||
-- {{.Belegdatum}} {{.UploadDate}} {{.Tags}} {{.OCRTitle}}
|
||||
-- Custom-Func dateFormat "02.01.2006" .Belegdatum (Go-Referenzdatum, leer bei
|
||||
-- unbekanntem Datum). Validierung: internal/storage/classification_templates_title.go
|
||||
-- ValidateTitleTemplate (Parse ohne Execute). Tenant-Default max. 500 Zeichen.
|
||||
|
||||
ALTER TABLE classification_templates
|
||||
ADD COLUMN IF NOT EXISTS title_template TEXT;
|
||||
|
||||
ALTER TABLE tenants
|
||||
ADD COLUMN IF NOT EXISTS default_title_template TEXT;
|
||||
@@ -0,0 +1,53 @@
|
||||
-- PROJ: retention-rules-engine
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über internal/storage/retention_rules.go
|
||||
-- initRetentionRulesSchema(), aufgerufen aus (*Store).initSchema().
|
||||
--
|
||||
-- GoBD-Aufbewahrungsregeln (retention rules / "Disposition Schedules",
|
||||
-- Namens-/Modellreferenz Alfresco, NICHT dessen Architektur):
|
||||
-- * Eine Regel definiert pro Dokumenttyp (oder tenant-weit als Default mit
|
||||
-- doc_type_id IS NULL) WIE LANGE ein Dokument aufbewahrt werden muss und
|
||||
-- ab WELCHEM Stichtag (trigger_type) die Frist zählt.
|
||||
-- * Der Batch-Job ApplyRetentionRules (CLI: `archivdms retention apply`)
|
||||
-- berechnet retain_until und SETZT es auf documents — er löscht NIEMALS
|
||||
-- und verkürzt eine bereits gesetzte Sperre NIE (GoBD: WORM nur
|
||||
-- verlängerbar). Die eigentliche Vernichtung läuft weiter über den
|
||||
-- Papierkorb + Vier-Augen-Workflow (007_trash.sql).
|
||||
-- * trigger_type:
|
||||
-- document_date -> documents.document_date, sonst created_at
|
||||
-- upload_date -> documents.created_at
|
||||
-- fixed_date -> trigger_reference als YYYY-MM-DD (einmaliger Stichtag)
|
||||
-- event -> NICHT auto-berechnet (z.B. Geschäftsjahresende /
|
||||
-- Vertragsende); Dokumente werden übersprungen, künftiger
|
||||
-- Erweiterungspunkt.
|
||||
-- * retain_until = Stichtag + retention_years Jahre + retention_days Tage.
|
||||
-- Für non-event-Regeln muss mindestens eines von years/days > 0 sein
|
||||
-- (im Go-Store validiert, nicht per CHECK).
|
||||
-- * Präzedenz: doc-typ-spezifische Regel schlägt die tenant-weite Default-
|
||||
-- Regel (doc_type_id IS NULL). UNIQUE(tenant_id, doc_type_id) erzwingt
|
||||
-- höchstens eine Regel pro (Mandant, Dokumenttyp), daher keine
|
||||
-- "strictest wins"-Logik nötig.
|
||||
-- * requires_approval_for_destroy / dsgvo_conflict sind informative Flags für
|
||||
-- das spätere Disposition-Frontend; die Vier-Augen-Pflicht selbst wird
|
||||
-- bereits vom Trash-Flow erzwungen.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS retention_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
doc_type_id BIGINT REFERENCES document_types(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
trigger_type TEXT NOT NULL
|
||||
CHECK (trigger_type IN ('document_date','upload_date','fixed_date','event')),
|
||||
trigger_reference TEXT NOT NULL DEFAULT '',
|
||||
retention_years INT,
|
||||
retention_days INT,
|
||||
legal_basis TEXT NOT NULL DEFAULT '',
|
||||
requires_approval_for_destroy BOOLEAN NOT NULL DEFAULT true,
|
||||
dsgvo_conflict BOOLEAN NOT NULL DEFAULT false,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_by BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(tenant_id, doc_type_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_retention_rules_tenant ON retention_rules(tenant_id) WHERE active;
|
||||
@@ -0,0 +1,59 @@
|
||||
-- PROJ: mandanten-job-queue (Phase 1+2)
|
||||
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
|
||||
-- Ausführung passiert idempotent über
|
||||
-- internal/storage/processing_jobs.go initProcessingJobsSchema(),
|
||||
-- aufgerufen aus (*Store).initSchema() (zuletzt in der Kette, da die Tabelle
|
||||
-- documents(id) FK-referenziert).
|
||||
--
|
||||
-- Mandanten-faire Verarbeitungs-Queue für die NACHGELAGERTE Dokument-
|
||||
-- verarbeitung (OCR-Extraktion, Taxonomie-Autozuordnung, on_upload-Workflows).
|
||||
-- Vorher lief das synchron im Upload-Request und erzeugte bei Batch-Scans /
|
||||
-- SFTP-Massenuploads Lastspitzen.
|
||||
--
|
||||
-- * Kein Redis: die Queue ist eine ganz normale Postgres-Tabelle. Dokument-
|
||||
-- INSERT und Job-INSERT laufen in EINER Transaktion
|
||||
-- (CreateDocumentWithJob) — nie ein Dokument ohne Job, nie ein Job ohne
|
||||
-- Dokument.
|
||||
-- * WORM bleibt synchron im Request: Hash, Ablage unter
|
||||
-- store/<tenant>/<yyyy>/<mm>/<sha256>.<ext> und chmod 0440 passieren VOR
|
||||
-- dem INSERT. Der Job liest die archivierte Datei nur und schreibt
|
||||
-- ausschließlich abgeleitete Metadaten.
|
||||
-- * Dispatch: Round-Robin über die Mandanten (je Runde ein Job pro Mandant),
|
||||
-- Locking über FOR UPDATE SKIP LOCKED.
|
||||
-- * Retry: exponentielles Backoff über next_attempt_at (2^retry_count
|
||||
-- Sekunden). Ab retry_count > max_retries (Default 5) bleibt der Job
|
||||
-- dauerhaft 'failed' — kein Automatik-Retry mehr.
|
||||
-- * derive_title: merkt sich, ob der Titel beim Staging nur ein Platzhalter
|
||||
-- war und aus dem OCR-Text ersetzt werden darf. Ein vom Benutzer bzw. aus
|
||||
-- dem SFTP-Dateinamen vorgegebener Titel wird nie überschrieben.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS processing_jobs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'queued', -- queued | processing | done | failed
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
derive_title BOOLEAN NOT NULL DEFAULT false,
|
||||
error_message TEXT,
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE processing_jobs ADD COLUMN IF NOT EXISTS derive_title BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE processing_jobs ADD COLUMN IF NOT EXISTS next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now();
|
||||
ALTER TABLE processing_jobs ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ;
|
||||
ALTER TABLE processing_jobs ADD COLUMN IF NOT EXISTS error_message TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_processing_jobs_dispatch
|
||||
ON processing_jobs(tenant_id, status, next_attempt_at, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_processing_jobs_document
|
||||
ON processing_jobs(document_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_processing_jobs_status
|
||||
ON processing_jobs(status);
|
||||
|
||||
-- Anzeige-/Ablaufstatus am Dokument. Default 'done': der komplette Altbestand
|
||||
-- wurde noch synchron verarbeitet und ist per Definition fertig — bewusst KEIN
|
||||
-- Backfill-Skript, der Spalten-Default deckt alle Bestandszeilen ab.
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS processing_status TEXT NOT NULL DEFAULT 'done';
|
||||
@@ -0,0 +1,46 @@
|
||||
-- 024_ocr_words.sql
|
||||
-- Documentation only — applied via internal/storage/ocr_words.go
|
||||
-- (Store.initOCRWordsSchema), wired into Store.initSchema in documents.go.
|
||||
--
|
||||
-- Phase 2 of the OCR text-highlight/overlay feature (Phase 1: internal/ocr
|
||||
-- gained Result.Words []WordBox, coordinates already mapped back into the
|
||||
-- original uploaded file's coordinate space — see internal/ocr/coords.go).
|
||||
-- This migration adds the table that persists those word boxes per
|
||||
-- document, so a future Phase 3 read endpoint can serve them for a
|
||||
-- search-highlight/overlay UI without re-running OCR.
|
||||
--
|
||||
-- No tenant_id column: access is always mediated through document_id (join
|
||||
-- or verify against documents.tenant_id), never queried directly by tenant.
|
||||
--
|
||||
-- ON DELETE CASCADE on document_id: word boxes are a derived index over a
|
||||
-- document's OCR text/original file, not a GoBD document themselves, so
|
||||
-- cascading on hard-delete (trash workflow, see 007_trash.sql) is correct
|
||||
-- and does not affect WORM retention of the document's own storage_path/
|
||||
-- content_hash.
|
||||
--
|
||||
-- Every OCR (re)run (upload async job, manual reprocess endpoint, and the
|
||||
-- `documents reprocess-all` CLI, which shares ReprocessDocument with the
|
||||
-- endpoint) deletes this document's existing rows and re-inserts the fresh
|
||||
-- set (storage.ReplaceOCRWords), so re-OCR never accumulates duplicates.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ocr_words (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
page INTEGER NOT NULL DEFAULT 1,
|
||||
block INTEGER NOT NULL DEFAULT 0,
|
||||
par INTEGER NOT NULL DEFAULT 0,
|
||||
line INTEGER NOT NULL DEFAULT 0,
|
||||
word_text TEXT NOT NULL,
|
||||
"left" INTEGER NOT NULL,
|
||||
top INTEGER NOT NULL,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
confidence DOUBLE PRECISION NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ocr_words_document ON ocr_words (document_id);
|
||||
|
||||
-- Plain btree, not pg_trgm/GIN: initSchema cannot assume CREATE EXTENSION
|
||||
-- privileges on the live server. Revisit if Phase 3 needs fuzzy/substring
|
||||
-- prefiltering (would require enabling pg_trgm out-of-band first).
|
||||
CREATE INDEX IF NOT EXISTS idx_ocr_words_word_text ON ocr_words (word_text);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- 025_document_date_score.sql
|
||||
-- Documentation only — applied via internal/storage/documents.go
|
||||
-- (Store.initSchema), idempotent ALTER TABLE ADD COLUMN IF NOT EXISTS.
|
||||
--
|
||||
-- Persists the confidence score that internal/api's
|
||||
-- extractDocumentDateWithScore (and its byte-synchronous storage-package
|
||||
-- twin, documentDateFromTextWithScore in document_date.go) already computed
|
||||
-- at runtime for documents.document_date, but previously discarded — only
|
||||
-- the resulting date was ever written to the DB.
|
||||
--
|
||||
-- Purpose: quality gate for the planned Buchhaltungs-Pull-API (see
|
||||
-- MEMORY.md project_belegdatum_und_buchhaltung) — only documents whose
|
||||
-- document_date_score >= 0.75 should be automatically pullable.
|
||||
--
|
||||
-- Nullable, NUMERIC, no default and NO backfill run in this migration:
|
||||
-- existing rows have an unknown (not zero) confidence for their existing
|
||||
-- document_date, so NULL is the only correct value for them. A future
|
||||
-- backfill pass, if ever needed, would have to re-run OCR-text scoring
|
||||
-- against the stored ocr_text — deliberately out of scope here.
|
||||
--
|
||||
-- Value convention:
|
||||
-- 0.4 - 0.9 automatic keyword-proximity heuristic (see
|
||||
-- scoreForDatePosition / documentDateScoreForPosition)
|
||||
-- 1.0 manually confirmed/overridden by a user via
|
||||
-- PUT /api/documents/{id}/document-date — a manual override
|
||||
-- always replaces any prior automatic score, it is never left
|
||||
-- stale after the user's explicit correction.
|
||||
-- NULL unknown / not yet computed (pre-existing rows, or
|
||||
-- document_date itself cleared to NULL).
|
||||
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS document_date_score NUMERIC;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- 026_accounting_api_keys.sql
|
||||
-- Documentation only — applied via
|
||||
-- internal/storage/accounting_api_keys.go (Store.initAccountingAPIKeysSchema),
|
||||
-- wired into storage.New() after initSharesSchema. Idempotent
|
||||
-- (CREATE TABLE / CREATE INDEX IF NOT EXISTS).
|
||||
--
|
||||
-- Per-tenant API keys for the read-only Buchhaltungs-Pull-API
|
||||
-- (GET /api/v1/accounting/documents[/{id}/file], siehe MEMORY.md
|
||||
-- project_belegdatum_und_buchhaltung). A key is a tenant-level MACHINE
|
||||
-- credential, not a user session: it grants read access to that tenant's
|
||||
-- archived documents and to nothing else, which is why creating one requires
|
||||
-- domain_admin.
|
||||
--
|
||||
-- Token handling mirrors document_shares (009_shares.sql) exactly:
|
||||
-- * raw key = "adms_" + base64url(32 crypto/rand bytes), generated once
|
||||
-- * returned to the caller EXACTLY once (POST response), never retrievable again
|
||||
-- * only the hex SHA-256 hash is persisted (key_hash, UNIQUE)
|
||||
-- * authentication always looks up by key_hash, never by id
|
||||
--
|
||||
-- Keys are never hard-deleted: revoking sets revoked_at, so the audit trail
|
||||
-- (audit event accounting_pull, Detail carries "key:<id>") stays resolvable for
|
||||
-- GoBD-Nachvollziehbarkeit. ResolveAccountingAPIKey filters revoked_at IS NULL
|
||||
-- inside the same UPDATE ... RETURNING that refreshes last_used_at, so a
|
||||
-- revoked key can never yield a tenant id.
|
||||
--
|
||||
-- No FK on tenant_id / created_by: consistent with the rest of the schema
|
||||
-- (plain BIGINT), because tenants/users are owned by other stores that
|
||||
-- initialise after storage.New().
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounting_api_keys (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
created_by BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revoked_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounting_api_keys_tenant ON accounting_api_keys(tenant_id);
|
||||
|
||||
-- No new columns on documents: the pull query (internal/storage/accounting_pull.go)
|
||||
-- reads existing columns only (document_date, document_date_score from
|
||||
-- 025_document_date_score.sql, doc_type_id, correspondent_id, created_at) and
|
||||
-- paginates by the (created_at, id) keyset, which idx_documents_tenant plus the
|
||||
-- primary key already support.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user