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