Files
paliad/internal/services/email_template_service.go
mAi 3d3a4fa36d feat(team-admin): t-paliad-223 Slice B — Add User via Supabase Admin API
#49 — adds a third "Konto direkt anlegen" path on /admin/team alongside
"Onboard existing" and "Invite colleague". Creates both auth.users (via
Supabase Admin API) and paliad.users in one click; new user is visible in
dropdowns immediately and receives a paliad-branded magic-link email.

- internal/services/supabase_admin.go: new SupabaseAdminClient — thin net/http shim. 3 methods (CreateAuthUser, GenerateRecoveryLink, DeleteAuthUser). 10s timeout. ErrSupabaseAdminUnavailable when key unset, ErrSupabaseEmailExists when 422-with-"already" returned. apikey + Bearer headers on every call. Sentinel errors for handler mapping.
- internal/services/supabase_admin_test.go: 5 tests pin wire-shape (disabled mode, happy-path POST + headers + body, email-exists mapping, both action-link response shapes, DELETE-by-id route).
- internal/services/user_service.go: UserService grows optional supabase + mail + baseURL dependencies via SetAddUserDeps. AdminCreateFullInput (email/display_name/office/job_title/profession/lang/send_welcome_mail + inviter fields). AdminCreateUserFull validates input → calls supabase.CreateAuthUser → inserts paliad.users (best-effort DeleteAuthUser rollback on insert fail) → writes paliad.system_audit_log row (event_type='user.added_by_admin') → sends welcome mail with magic-link (best-effort).
- internal/templates/email/add_user_welcome.{de,en}.html: new template with magic-link CTA + base-URL fallback + firm-name placeholder. Editable through the existing /admin/email-templates editor (admin-overridable via DB).
- internal/services/email_template_*.go: register 'add_user_welcome' as a fourth canonical key, defaultSubjects entry, sample data, variable contract (6 vars).
- internal/services/mail_service_test.go: TestRenderTemplateAddUserWelcome pins both langs render with magic-link + firm + matching subject.
- internal/handlers/admin_users.go: handleAdminCreateFullUser POST /api/admin/users/full. Fills inviter fields from auth.uid() server-side (never trusts the request body). Error map: 503 (unavailable), 409 (email exists / already onboarded), 400 (invalid input), 403 (domain not on whitelist), 500 (other).
- internal/handlers/handlers.go: route registered behind adminGate.
- cmd/server/main.go: LoadSupabaseAdminClient + users.SetAddUserDeps + boot-log line so the deployer knows whether the path is active.
- frontend/src/admin-team.tsx: "Konto direkt anlegen" button + admin-add-full-modal with email/name/office/profession/job_title/lang fields + send-welcome checkbox (default on).
- frontend/src/client/admin-team.ts: initAddFullModal — POST to /api/admin/users/full, inline error handling for 503 / 409 / generic, optimistic insert into users[] on success, name auto-fills from email local-part on blur.
- i18n: +20 keys (admin.team.add.full + admin.team.add_full.*) × DE + EN.

Design picks honoured: Supabase Admin API path (Q1), welcome email default on (Q2), two-step with best-effort rollback (Q3), job_title default 'Associate' (Q4), profession default 'associate' (Q5). Trade-off #3 from §6 (privileged credential broadens trust surface) accepted by m via head.

go build && go test -short ./internal/... + bun run build all green.
2026-05-20 15:19:48 +02:00

469 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package services — EmailTemplateService — manages the active and
// versioned email-template rows surfaced through /admin/email-templates.
//
// The service is intentionally tolerant of a nil DB: knowledge-platform-only
// deployments (no DATABASE_URL) still send invitations and reminders if
// SMTP is configured, falling back to the embedded per-language template
// files. Mutating methods (Save / Reset / RestoreVersion) require a DB and
// return ErrTemplateStoreUnavailable when called against a nil store.
//
// Active row precedence: a row in paliad.email_templates wins over the
// embedded default. Removing the row (Reset) restores the default. Saves
// also append to paliad.email_template_versions; the most recent
// EmailTemplateVersionRetention versions per (key, lang) are kept.
//
// See docs/design-email-templates-2026-04-29.md for the full design and
// the rationale for keeping subjects editable but seeded with explicit
// SLO-framing comments.
package services
import (
"context"
"database/sql"
"errors"
"fmt"
htmltemplate "html/template"
"slices"
"strings"
texttemplate "text/template"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/templates"
)
// Canonical template keys. The editor and seed loop iterate this list; new
// templates land by adding a key here and shipping a per-language body file
// under internal/templates/email/.
const (
EmailTemplateKeyInvitation = "invitation"
EmailTemplateKeyDeadlineDigest = "deadline_digest"
EmailTemplateKeyBase = "base"
// EmailTemplateKeyAddUserWelcome — t-paliad-223 Slice B (#49). Sent when
// a global_admin directly creates a paliad.users + auth.users pair from
// /admin/team's "Konto direkt anlegen" form. Carries a Supabase
// recovery-link so the new colleague can set their own password.
EmailTemplateKeyAddUserWelcome = "add_user_welcome"
)
// CanonicalEmailTemplateKeys is the closed set in canonical display order.
var CanonicalEmailTemplateKeys = []string{
EmailTemplateKeyInvitation,
EmailTemplateKeyAddUserWelcome,
EmailTemplateKeyDeadlineDigest,
EmailTemplateKeyBase,
}
// EmailTemplateVersionRetention caps the per-(key, lang) version history.
// Storage is negligible (3 keys × 2 langs × 20 = 120 rows steady-state) so
// 20 leaves enough headroom that the version a user wants to restore is
// almost always still around.
const EmailTemplateVersionRetention = 20
// EmailTemplateLanguages is the closed set of editor-supported languages.
var EmailTemplateLanguages = []string{"de", "en"}
// EmailTemplateRow is the active subject+body for one (key, lang). When the
// row came from the embedded fallback (no DB override) IsDefault is true and
// UpdatedAt / UpdatedBy are nil.
type EmailTemplateRow struct {
Key string `db:"key" json:"key"`
Lang string `db:"lang" json:"lang"`
Subject string `db:"subject" json:"subject"`
Body string `db:"body" json:"body"`
UpdatedAt *time.Time `db:"updated_at" json:"updated_at,omitempty"`
UpdatedBy *uuid.UUID `db:"updated_by" json:"updated_by,omitempty"`
IsDefault bool `db:"-" json:"is_default"`
}
// EmailTemplateVersionRow is one entry in the per-(key, lang) save log.
type EmailTemplateVersionRow struct {
ID uuid.UUID `db:"id" json:"id"`
Key string `db:"key" json:"key"`
Lang string `db:"lang" json:"lang"`
Subject string `db:"subject" json:"subject"`
Body string `db:"body" json:"body"`
SavedAt time.Time `db:"saved_at" json:"saved_at"`
SavedBy *uuid.UUID `db:"saved_by" json:"saved_by,omitempty"`
Note string `db:"note" json:"note"`
}
// Sentinel errors so handlers can map cleanly to status codes.
var (
ErrTemplateUnknownKey = errors.New("unknown email template key")
ErrTemplateUnknownLang = errors.New("unknown email template language")
ErrTemplateBodySyntax = errors.New("template body has invalid syntax")
ErrTemplateSubjectSyntax = errors.New("template subject has invalid syntax")
ErrTemplateMissingContent = errors.New(`template body must contain {{define "content"}}{{end}}`)
ErrTemplateMissingBaseBlock = errors.New(`base template must keep {{block "content" .}}{{end}}`)
ErrTemplateStoreUnavailable = errors.New("email template store unavailable (DATABASE_URL not set)")
ErrTemplateVersionNotFound = errors.New("email template version not found")
)
// EmailTemplateService is the read/write authority for active + versioned
// rows. db may be nil — see package docs.
type EmailTemplateService struct {
db *sqlx.DB
}
// NewEmailTemplateService accepts a possibly-nil DB. Callers can hand it the
// shared sqlx.DB pool or pass nil for fallback-only mode.
func NewEmailTemplateService(db *sqlx.DB) *EmailTemplateService {
return &EmailTemplateService{db: db}
}
// HasStore reports whether mutating operations will succeed. False during
// knowledge-platform-only deployments.
func (s *EmailTemplateService) HasStore() bool { return s != nil && s.db != nil }
// IsCanonicalKey reports whether key is in the editor's closed set.
func IsCanonicalKey(key string) bool {
return slices.Contains(CanonicalEmailTemplateKeys, key)
}
func canonicaliseLang(lang string) (string, error) {
switch lang {
case "":
return "de", nil
case "de", "en":
return lang, nil
default:
return "", ErrTemplateUnknownLang
}
}
// GetActive returns the active subject+body for (key, lang). DB row wins
// when present; the embedded default applies otherwise. Errors only when
// the key/lang are unknown or the embedded default is missing.
func (s *EmailTemplateService) GetActive(ctx context.Context, key, lang string) (EmailTemplateRow, error) {
if !IsCanonicalKey(key) {
return EmailTemplateRow{}, ErrTemplateUnknownKey
}
lang, err := canonicaliseLang(lang)
if err != nil {
return EmailTemplateRow{}, err
}
if s.HasStore() {
var row EmailTemplateRow
err := s.db.GetContext(ctx, &row, `
SELECT key, lang, subject, body, updated_at, updated_by
FROM paliad.email_templates
WHERE key = $1 AND lang = $2`, key, lang)
if err == nil {
return row, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return EmailTemplateRow{}, fmt.Errorf("read email_templates: %w", err)
}
}
return embeddedDefault(key, lang)
}
// embeddedDefault returns the on-disk default for (key, lang). Subject
// defaults are Go consts (defaultSubjects) for two reasons: subjects are
// short, and the digest subject's conditional logic benefits from being
// colocated with the service that documents its intent.
func embeddedDefault(key, lang string) (EmailTemplateRow, error) {
body, err := readEmbeddedBody(key, lang)
if err != nil {
return EmailTemplateRow{}, err
}
return EmailTemplateRow{
Key: key,
Lang: lang,
Subject: defaultSubjects[key][lang],
Body: body,
IsDefault: true,
}, nil
}
func readEmbeddedBody(key, lang string) (string, error) {
path := fmt.Sprintf("email/%s.%s.html", key, lang)
data, err := templates.EmailFS.ReadFile(path)
if err != nil {
return "", fmt.Errorf("embedded body %s: %w", path, err)
}
return string(data), nil
}
// SaveInput is the payload for Save. Validation happens before any DB
// interaction so a typo is reported as 422 without a tx round-trip.
type SaveInput struct {
Key string
Lang string
Subject string
Body string
Note string // free-form admin annotation, optional
SavedBy uuid.UUID // uuid.Nil when actor is unknown — column is nullable
}
// Save validates and upserts the active row, appends a version, and GCs the
// version log to EmailTemplateVersionRetention. All in one transaction.
func (s *EmailTemplateService) Save(ctx context.Context, in SaveInput) (EmailTemplateVersionRow, error) {
if !s.HasStore() {
return EmailTemplateVersionRow{}, ErrTemplateStoreUnavailable
}
if !IsCanonicalKey(in.Key) {
return EmailTemplateVersionRow{}, ErrTemplateUnknownKey
}
lang, err := canonicaliseLang(in.Lang)
if err != nil {
return EmailTemplateVersionRow{}, err
}
in.Lang = lang
if err := ValidateTemplate(in.Key, in.Subject, in.Body); err != nil {
return EmailTemplateVersionRow{}, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return EmailTemplateVersionRow{}, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `
INSERT INTO paliad.email_templates (key, lang, subject, body, updated_at, updated_by)
VALUES ($1, $2, $3, $4, now(), $5)
ON CONFLICT (key, lang) DO UPDATE
SET subject = EXCLUDED.subject,
body = EXCLUDED.body,
updated_at = now(),
updated_by = EXCLUDED.updated_by`,
in.Key, in.Lang, in.Subject, in.Body, nullableUUID(in.SavedBy)); err != nil {
return EmailTemplateVersionRow{}, fmt.Errorf("upsert active: %w", err)
}
ver, err := insertVersion(ctx, tx, in.Key, in.Lang, in.Subject, in.Body, in.SavedBy, in.Note)
if err != nil {
return EmailTemplateVersionRow{}, err
}
if err := gcVersions(ctx, tx, in.Key, in.Lang); err != nil {
return EmailTemplateVersionRow{}, err
}
if err := tx.Commit(); err != nil {
return EmailTemplateVersionRow{}, fmt.Errorf("commit: %w", err)
}
return ver, nil
}
// Reset deletes the active row and appends a 'reset' version capturing the
// embedded default. Subsequent renders fall back to the embedded body.
func (s *EmailTemplateService) Reset(ctx context.Context, key, lang string, savedBy uuid.UUID) (EmailTemplateVersionRow, error) {
if !s.HasStore() {
return EmailTemplateVersionRow{}, ErrTemplateStoreUnavailable
}
if !IsCanonicalKey(key) {
return EmailTemplateVersionRow{}, ErrTemplateUnknownKey
}
lang, err := canonicaliseLang(lang)
if err != nil {
return EmailTemplateVersionRow{}, err
}
def, err := embeddedDefault(key, lang)
if err != nil {
return EmailTemplateVersionRow{}, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return EmailTemplateVersionRow{}, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.email_templates WHERE key = $1 AND lang = $2`,
key, lang); err != nil {
return EmailTemplateVersionRow{}, fmt.Errorf("delete active: %w", err)
}
ver, err := insertVersion(ctx, tx, key, lang, def.Subject, def.Body, savedBy, "reset")
if err != nil {
return EmailTemplateVersionRow{}, err
}
if err := gcVersions(ctx, tx, key, lang); err != nil {
return EmailTemplateVersionRow{}, err
}
if err := tx.Commit(); err != nil {
return EmailTemplateVersionRow{}, fmt.Errorf("commit: %w", err)
}
return ver, nil
}
// ListVersions returns the most recent EmailTemplateVersionRetention rows.
func (s *EmailTemplateService) ListVersions(ctx context.Context, key, lang string) ([]EmailTemplateVersionRow, error) {
if !s.HasStore() {
return nil, ErrTemplateStoreUnavailable
}
if !IsCanonicalKey(key) {
return nil, ErrTemplateUnknownKey
}
lang, err := canonicaliseLang(lang)
if err != nil {
return nil, err
}
rows := []EmailTemplateVersionRow{}
if err := s.db.SelectContext(ctx, &rows, `
SELECT id, key, lang, subject, body, saved_at, saved_by, note
FROM paliad.email_template_versions
WHERE key = $1 AND lang = $2
ORDER BY saved_at DESC
LIMIT $3`, key, lang, EmailTemplateVersionRetention); err != nil {
return nil, fmt.Errorf("list versions: %w", err)
}
return rows, nil
}
// RestoreVersion copies a historical version back into the active row,
// appending a fresh version that records the restore source.
func (s *EmailTemplateService) RestoreVersion(ctx context.Context, key, lang string, versionID, savedBy uuid.UUID) (EmailTemplateVersionRow, error) {
if !s.HasStore() {
return EmailTemplateVersionRow{}, ErrTemplateStoreUnavailable
}
if !IsCanonicalKey(key) {
return EmailTemplateVersionRow{}, ErrTemplateUnknownKey
}
lang, err := canonicaliseLang(lang)
if err != nil {
return EmailTemplateVersionRow{}, err
}
var src EmailTemplateVersionRow
err = s.db.GetContext(ctx, &src, `
SELECT id, key, lang, subject, body, saved_at, saved_by, note
FROM paliad.email_template_versions
WHERE id = $1 AND key = $2 AND lang = $3`, versionID, key, lang)
if errors.Is(err, sql.ErrNoRows) {
return EmailTemplateVersionRow{}, ErrTemplateVersionNotFound
}
if err != nil {
return EmailTemplateVersionRow{}, fmt.Errorf("fetch version: %w", err)
}
return s.Save(ctx, SaveInput{
Key: key,
Lang: lang,
Subject: src.Subject,
Body: src.Body,
Note: fmt.Sprintf("restore from %s", versionID),
SavedBy: savedBy,
})
}
// ValidateTemplate checks subject + body against the templating engines.
// The structural checks ensure content templates re-define {{block "content"}}
// (otherwise the body silently vanishes inside the base wrapper) and that
// the base body still contains the {{block "content" .}} call (otherwise
// every email loses its body). Returns nil iff both are syntactically and
// structurally valid.
func ValidateTemplate(key, subject, body string) error {
if subject != "" {
if _, err := texttemplate.New("subject").Parse(subject); err != nil {
return fmt.Errorf("%w: %v", ErrTemplateSubjectSyntax, err)
}
}
if _, err := htmltemplate.New("body").Parse(body); err != nil {
return fmt.Errorf("%w: %v", ErrTemplateBodySyntax, err)
}
if key == EmailTemplateKeyBase {
if !strings.Contains(body, `block "content"`) {
return ErrTemplateMissingBaseBlock
}
} else {
if !strings.Contains(body, `define "content"`) {
return ErrTemplateMissingContent
}
}
return nil
}
// --- internal helpers -------------------------------------------------------
func insertVersion(ctx context.Context, tx *sqlx.Tx, key, lang, subject, body string, savedBy uuid.UUID, note string) (EmailTemplateVersionRow, error) {
var ver EmailTemplateVersionRow
err := tx.GetContext(ctx, &ver, `
INSERT INTO paliad.email_template_versions (key, lang, subject, body, saved_by, note)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, key, lang, subject, body, saved_at, saved_by, note`,
key, lang, subject, body, nullableUUID(savedBy), note)
if err != nil {
return EmailTemplateVersionRow{}, fmt.Errorf("insert version: %w", err)
}
return ver, nil
}
func gcVersions(ctx context.Context, tx *sqlx.Tx, key, lang string) error {
if _, err := tx.ExecContext(ctx, `
DELETE FROM paliad.email_template_versions
WHERE key = $1 AND lang = $2
AND id NOT IN (
SELECT id FROM paliad.email_template_versions
WHERE key = $1 AND lang = $2
ORDER BY saved_at DESC
LIMIT $3
)`, key, lang, EmailTemplateVersionRetention); err != nil {
return fmt.Errorf("gc versions: %w", err)
}
return nil
}
func nullableUUID(u uuid.UUID) any {
if u == uuid.Nil {
return nil
}
return u
}
// defaultSubjects is the embedded-fallback subject template per (key, lang).
// They're text/template (not html/template); subjects are plain strings,
// no HTML escaping required.
//
// Keep the SYSTEMAUSFALL / SYSTEM FAILURE phrasing — see
// docs/design-reminder-redesign-2026-04-28.md. Softening it weakens the
// zero-overdue SLO and the comment in the seed exists so the next admin
// who edits sees the rationale instead of pattern-matching the framing as
// noise.
var defaultSubjects = map[string]map[string]string{
EmailTemplateKeyInvitation: {
"de": `[Paliad] {{.InviterName}} lädt Sie zu Paliad ein`,
"en": `[Paliad] {{.InviterName}} invites you to Paliad`,
},
EmailTemplateKeyAddUserWelcome: {
"de": `[Paliad] Ihr Paliad-Konto ist bereit`,
"en": `[Paliad] Your Paliad account is ready`,
},
EmailTemplateKeyDeadlineDigest: {
"de": digestSubjectDE,
"en": digestSubjectEN,
},
EmailTemplateKeyBase: {"de": "", "en": ""},
}
const digestSubjectDE = `{{- /* keep the SYSTEMAUSFALL phrasing — see docs/design-reminder-redesign-2026-04-28.md */ -}}
{{- if and .IsEvening (gt .OverdueCount 0) -}}
[Paliad] SYSTEMAUSFALL: {{.OverdueCount}} überfällig — plus {{.DueTodayCount}} heute offen
{{- else if and .IsEvening (gt .DueTodayCount 0) -}}
[Paliad] DRINGEND — {{.DueTodayCount}} heute noch offen
{{- else if .IsEvening -}}
[Paliad] {{.OverdueCount}} überfällig
{{- else if gt .OverdueCount 0 -}}
[Paliad] ÜBERFÄLLIG: {{.OverdueCount}} — plus {{.OpenTotal}} weitere
{{- else if eq .OpenTotal 1 -}}
[Paliad] Frist-Erinnerung: 1 offen
{{- else -}}
[Paliad] Frist-Erinnerung: {{.OpenTotal}} offen
{{- end -}}`
const digestSubjectEN = `{{- /* keep the SYSTEM FAILURE phrasing — see docs/design-reminder-redesign-2026-04-28.md */ -}}
{{- if and .IsEvening (gt .OverdueCount 0) -}}
[Paliad] SYSTEM FAILURE: {{.OverdueCount}} overdue — plus {{.DueTodayCount}} still open today
{{- else if and .IsEvening (gt .DueTodayCount 0) -}}
[Paliad] URGENT — {{.DueTodayCount}} still open today
{{- else if .IsEvening -}}
[Paliad] {{.OverdueCount}} overdue
{{- else if gt .OverdueCount 0 -}}
[Paliad] OVERDUE: {{.OverdueCount}} — plus {{.OpenTotal}} more
{{- else if eq .OpenTotal 1 -}}
[Paliad] Deadline reminder: 1 open
{{- else -}}
[Paliad] Deadline reminder: {{.OpenTotal}} open
{{- end -}}`