// Package tenantstore implements the application-level multi-tenancy pattern // ported from archivmail: tenants are a plain table, and every query // elsewhere in the application filters manually by tenant_id — no Postgres // row-level security is used (deliberate, keeps things simple). package tenantstore import ( "context" "fmt" "strings" "time" "unicode/utf8" "archivdms/internal/dateformat" "github.com/jackc/pgx/v5/pgxpool" ) // Tenant represents an organisation/mandant in the system. type Tenant struct { ID int64 `json:"id"` Name string `json:"name"` Slug string `json:"slug"` Domain string `json:"domain,omitempty"` CreatedAt time.Time `json:"created_at"` // ScanTitleDateFormat selects the timestamp layout used for the // placeholder title ("Scan ") generated at upload time when no // title is supplied and OCR yields no usable heading. It stores a free // token pattern (e.g. "DD.MM.YYYY HH:mm"), not a raw Go layout string — // translated to a Go layout via internal/dateformat at render time. Empty // means "use the default". ScanTitleDateFormat string `json:"scan_title_date_format"` // ScanTitlePrefix is the leading word of the placeholder title generated at // upload time (" ", e.g. "Scan 16.07.2026 14:30"). Configurable // per tenant (e.g. "Beleg", "Import", "Eingang"). Empty means "use the default". ScanTitlePrefix string `json:"scan_title_prefix"` // DefaultTitleTemplate is the tenant-wide fallback Go text/template pattern // used to derive a document title when a classification template is applied // but that template itself carries no title_template. Empty means "no // tenant default" — in that case a template application without its own // title_template leaves the document's title untouched. DefaultTitleTemplate string `json:"default_title_template"` } // Store is a PostgreSQL-backed tenant store. type Store struct { pool *pgxpool.Pool } // New connects to PostgreSQL and initialises the tenants schema. func New(dsn string) (*Store, error) { ctx := context.Background() pool, err := pgxpool.New(ctx, dsn) if err != nil { return nil, fmt.Errorf("tenantstore: connect: %w", err) } s := &Store{pool: pool} if err := s.initSchema(ctx); err != nil { pool.Close() return nil, fmt.Errorf("tenantstore: init schema: %w", err) } return s, nil } func (s *Store) initSchema(ctx context.Context) error { _, err := s.pool.Exec(ctx, ` 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; ALTER TABLE tenants ADD COLUMN IF NOT EXISTS scan_title_date_format TEXT NOT NULL DEFAULT 'DD.MM.YYYY HH:mm'; ALTER TABLE tenants ADD COLUMN IF NOT EXISTS scan_title_prefix TEXT NOT NULL DEFAULT 'Scan'; ALTER TABLE tenants ADD COLUMN IF NOT EXISTS default_title_template TEXT; `) return err } // Close closes the underlying connection pool. func (s *Store) Close() error { s.pool.Close() return nil } // Create inserts a new tenant. func (s *Store) Create(ctx context.Context, name, slug, domain string) (*Tenant, error) { var t Tenant var domainVal any if domain != "" { domainVal = domain } err := s.pool.QueryRow(ctx, `INSERT INTO tenants (name, slug, domain) VALUES ($1, $2, $3) RETURNING id, name, slug, COALESCE(domain, ''), created_at, COALESCE(scan_title_date_format, ''), COALESCE(scan_title_prefix, ''), COALESCE(default_title_template, '')`, name, slug, domainVal, ).Scan(&t.ID, &t.Name, &t.Slug, &t.Domain, &t.CreatedAt, &t.ScanTitleDateFormat, &t.ScanTitlePrefix, &t.DefaultTitleTemplate) if err != nil { return nil, fmt.Errorf("tenantstore: create: %w", err) } return &t, nil } // GetByID retrieves a tenant by ID. func (s *Store) GetByID(ctx context.Context, id int64) (*Tenant, error) { var t Tenant err := s.pool.QueryRow(ctx, `SELECT id, name, slug, COALESCE(domain, ''), created_at, COALESCE(scan_title_date_format, ''), COALESCE(scan_title_prefix, ''), COALESCE(default_title_template, '') FROM tenants WHERE id = $1`, id, ).Scan(&t.ID, &t.Name, &t.Slug, &t.Domain, &t.CreatedAt, &t.ScanTitleDateFormat, &t.ScanTitlePrefix, &t.DefaultTitleTemplate) if err != nil { return nil, fmt.Errorf("tenantstore: get: %w", err) } return &t, nil } // UpdateScanTitleDateFormat sets the placeholder-title timestamp format for a // tenant. The format is a free token pattern (e.g. "DD.MM.YYYY HH:mm"); it is // validated by translating it to a Go layout via dateformat.Translate. The // raw token string (not the translated layout) is stored so it stays // human-readable; translation happens again at title-generation time. func (s *Store) UpdateScanTitleDateFormat(ctx context.Context, tenantID int64, format string) error { if _, err := dateformat.Translate(format); err != nil { return fmt.Errorf("tenantstore: update scan_title_date_format: %w", err) } ct, err := s.pool.Exec(ctx, `UPDATE tenants SET scan_title_date_format = $2 WHERE id = $1`, tenantID, format) if err != nil { return fmt.Errorf("tenantstore: update scan_title_date_format: %w", err) } if ct.RowsAffected() == 0 { return fmt.Errorf("tenantstore: update scan_title_date_format: tenant %d not found", tenantID) } return nil } // UpdateScanTitlePrefix sets the placeholder-title prefix word for a tenant. // The prefix must be non-empty and at most 40 characters; validation happens // here so a bad value can never be persisted. func (s *Store) UpdateScanTitlePrefix(ctx context.Context, tenantID int64, prefix string) error { trimmed := strings.TrimSpace(prefix) if trimmed == "" { return fmt.Errorf("tenantstore: update scan_title_prefix: prefix must not be empty") } if utf8.RuneCountInString(trimmed) > 40 { return fmt.Errorf("tenantstore: update scan_title_prefix: prefix too long (max 40 characters)") } ct, err := s.pool.Exec(ctx, `UPDATE tenants SET scan_title_prefix = $2 WHERE id = $1`, tenantID, trimmed) if err != nil { return fmt.Errorf("tenantstore: update scan_title_prefix: %w", err) } if ct.RowsAffected() == 0 { return fmt.Errorf("tenantstore: update scan_title_prefix: tenant %d not found", tenantID) } return nil } // UpdateDefaultTitleTemplate sets the tenant-wide fallback title template // (Go text/template pattern applied when a classification template without its // own title_template is used). An empty/whitespace-only value clears the // tenant default (stored as NULL) — restoring the "leave title untouched" // behaviour. The template body is capped at 500 characters; syntactic // validation of the pattern happens in the API layer before this is called. func (s *Store) UpdateDefaultTitleTemplate(ctx context.Context, tenantID int64, tmpl string) error { trimmed := strings.TrimSpace(tmpl) if utf8.RuneCountInString(trimmed) > 500 { return fmt.Errorf("tenantstore: update default_title_template: template too long (max 500 characters)") } var val any if trimmed != "" { val = trimmed } ct, err := s.pool.Exec(ctx, `UPDATE tenants SET default_title_template = $2 WHERE id = $1`, tenantID, val) if err != nil { return fmt.Errorf("tenantstore: update default_title_template: %w", err) } if ct.RowsAffected() == 0 { return fmt.Errorf("tenantstore: update default_title_template: tenant %d not found", tenantID) } return nil } // List returns all tenants. func (s *Store) List(ctx context.Context) ([]*Tenant, error) { rows, err := s.pool.Query(ctx, `SELECT id, name, slug, COALESCE(domain, ''), created_at, COALESCE(scan_title_date_format, ''), COALESCE(scan_title_prefix, ''), COALESCE(default_title_template, '') FROM tenants ORDER BY id`) if err != nil { return nil, fmt.Errorf("tenantstore: list: %w", err) } defer rows.Close() out := make([]*Tenant, 0) for rows.Next() { var t Tenant if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Domain, &t.CreatedAt, &t.ScanTitleDateFormat, &t.ScanTitlePrefix, &t.DefaultTitleTemplate); err != nil { return nil, fmt.Errorf("tenantstore: scan: %w", err) } out = append(out, &t) } return out, rows.Err() } // GetTenantIDByDomain implements auth.TenantDomainLookup: resolves a domain to // a tenant ID, or nil if no tenant claims that domain. func (s *Store) GetTenantIDByDomain(ctx context.Context, domain string) (*int64, error) { var id int64 err := s.pool.QueryRow(ctx, `SELECT id FROM tenants WHERE domain = $1`, domain).Scan(&id) if err != nil { return nil, nil //nolint:nilerr // "not found" is not an error for this lookup } return &id, nil }