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,217 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user