Files
paliad/internal/services/email_template_samples.go
mAi 96aef9b5dd 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:18:42 +02:00

143 lines
4.7 KiB
Go

// Sample data for the /admin/email-templates preview pane. Each preview
// request renders the proposed subject + body against this fixed payload
// so the admin sees the layout exactly as it will look in production for a
// representative case. Sample data is server-authoritative; the editor
// can't override it (out of scope for v1 — see design doc §9).
package services
// EmailTemplateSampleData returns a fresh sample payload for (key, lang).
// `slot` is honoured for deadline_digest only ("morning" / "evening"); other
// keys ignore it.
func EmailTemplateSampleData(key, lang, slot string) map[string]any {
switch key {
case EmailTemplateKeyInvitation:
return invitationSample(lang)
case EmailTemplateKeyAddUserWelcome:
return addUserWelcomeSample(lang)
case EmailTemplateKeyDeadlineDigest:
return deadlineDigestSample(lang, slot)
case EmailTemplateKeyBase:
return baseSample(lang)
default:
return map[string]any{}
}
}
func invitationSample(lang string) map[string]any {
if lang == "en" {
return map[string]any{
"InviterName": "Maria Schmidt",
"InviterEmail": "maria.schmidt@hlc.com",
"ToEmail": "new.colleague@hlc.com",
"Message": "Hi — I think you'd find Paliad useful. Have a look.",
"RegisterURL": "https://paliad.de/login",
}
}
return map[string]any{
"InviterName": "Maria Schmidt",
"InviterEmail": "maria.schmidt@hlc.com",
"ToEmail": "neu.kollege@hlc.de",
"Message": "Hallo Kolleg:in — ich glaube Paliad wäre nützlich für dich. Schau es dir an.",
"RegisterURL": "https://paliad.de/login",
}
}
func deadlineDigestSample(lang, slot string) map[string]any {
isEvening := slot == "evening"
overdue := []map[string]any{
{
"DueDate": "2026-04-27",
"Title": ifLang(lang, "Beschwerde gegen EP-Anmeldung einreichen", "File appeal against EP application"),
"ProjectReference": "HL-2024-0083",
"ProjectTitle": "Acme vs Beta GmbH",
"OwnerName": "Maria Schmidt",
"IsOtherOwner": true,
"URL": "https://paliad.de/deadlines/sample-overdue-1",
},
}
dueToday := []map[string]any{
{
"DueDate": "2026-04-29",
"Title": ifLang(lang, "Klageerwiderung einreichen", "File reply to complaint"),
"ProjectReference": "HL-2025-0011",
"ProjectTitle": "Gamma AG vs Delta Inc",
"OwnerName": "Self",
"IsOtherOwner": false,
"URL": "https://paliad.de/deadlines/sample-due-today-1",
},
{
"DueDate": "2026-04-29",
"Title": ifLang(lang, "Vollmacht prüfen und gegenzeichnen", "Review and counter-sign power of attorney"),
"ProjectReference": "HL-2025-0014",
"ProjectTitle": "",
"OwnerName": "Self",
"IsOtherOwner": false,
"URL": "https://paliad.de/deadlines/sample-due-today-2",
},
}
dueWarning := []map[string]any{
{
"DueDate": "2026-05-06",
"Title": ifLang(lang, "Stellungnahme zur Erteilung vorbereiten", "Prepare response to grant"),
"ProjectReference": "HL-2025-0007",
"ProjectTitle": "Epsilon Ltd",
"OwnerName": "Self",
"IsOtherOwner": false,
"URL": "https://paliad.de/deadlines/sample-warning-1",
},
}
return map[string]any{
"Slot": slot,
"IsEvening": isEvening,
"Overdue": overdue,
"OverdueCount": len(overdue),
"DueToday": dueToday,
"DueTodayCount": len(dueToday),
"DueWarning": dueWarning,
"DueWarningCount": len(dueWarning),
"OpenTotal": len(dueToday) + len(dueWarning),
"DeadlinesURL": "https://paliad.de/deadlines",
}
}
// t-paliad-223 Slice B (#49) — sample data for the Add-User welcome mail.
// The variable contract mirrors what UserService.AdminCreateUserFull
// passes to MailService.SendTemplate at runtime.
func addUserWelcomeSample(lang string) map[string]any {
if lang == "en" {
return map[string]any{
"InviterName": "Maria Schmidt",
"InviterEmail": "maria.schmidt@hlc.com",
"ToEmail": "new.colleague@hlc.com",
"MagicLink": "https://supabase.paliad.de/auth/v1/verify?token=…",
"BaseURL": "https://paliad.de",
"Firm": "HLC",
}
}
return map[string]any{
"InviterName": "Maria Schmidt",
"InviterEmail": "maria.schmidt@hlc.com",
"ToEmail": "neu.kollege@hlc.de",
"MagicLink": "https://supabase.paliad.de/auth/v1/verify?token=…",
"BaseURL": "https://paliad.de",
"Firm": "HLC",
}
}
func baseSample(lang string) map[string]any {
subj := "Beispielbetreff"
if lang == "en" {
subj = "Example subject"
}
return map[string]any{
"Subject": subj,
}
}
func ifLang(lang, de, en string) string {
if lang == "en" {
return en
}
return de
}