L15 Testphase & Onboarding: Selbstanmeldung mit Double-Opt-in, Plattform-Wizard, Nur-Lesen-Sperre, Export, Lebenszyklus-Job

- Datenmodell: Testphasen-Lebenszyklus am Mandanten (plan, trialEndsAt, readOnlySince, deletionDueAt,
  Versandmarker), TrialSignup (Plattform, Hashes statt Klartext), TenantExport (RLS), Onboarding-Status
- /testen: 5-Schritte-Wizard (Betrieb, Admin-Konto, Enddatum, Einrichtung, Zusammenfassung),
  Bestätigung per POST, direkte Anmeldung über login-ticket; Rate-Limit je IP/E-Mail, Honeypot,
  Enumeration-Schutz, Slug-Kollisionen
- Plattform: Wizard „Testmandant anlegen“ mit Einladung, Badges/Filter, Enddatum ändern,
  umwandeln, beenden, Löschung vormerken/abbrechen (Bestätigung + Audit)
- Schreibsperre nach Ablauf zentral in moduleGuard und requireApiContext (non-GET über withApi),
  Upload-Routen, Einstellungen/Nutzerverwaltung, Worker-Jobs; Banner Backoffice + mobil
- Datenexport (ZIP mit CSV/JSON + Dateien) als Worker-Job, auch im Nur-Lesen-Zustand
- Täglicher Job trial-lifecycle: Erinnerungen 7/3/1, Ablauf, Löschhinweis, Löschung über das Offboarding
- Erste-Schritte-Checkliste im Dashboard, Mail-Vorlagen de/en, Tests + Smoke, Betriebsdoku

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 19:01:47 +02:00
co-authored by Claude Opus 5
parent 6b8cdf543b
commit d9290a187c
79 changed files with 4576 additions and 19 deletions
+135
View File
@@ -66,6 +66,12 @@ export type TemplateVars = {
craftvia_report_customer: {
customerName: string; tenantName: string; reportTitle: string; reportDate: string; message?: string;
};
// ---- L15 Testphase (Transaktions-/Pflichtmails, nicht abbestellbar)
trial_confirm: { name: string; companyName: string; trialEnd: string; actionUrl: string; expires: string };
trial_existing_account: { name: string; loginUrl: string; resetUrl: string };
trial_reminder: { name: string; tenantName: string; daysLeft: number; endDate: string; actionUrl: string; contact?: string };
trial_expired: { name: string; tenantName: string; endDate: string; deletionDate?: string; exportUrl: string; contact?: string };
trial_deletion_notice: { name: string; tenantName: string; deletionDate: string; exportUrl: string; contact?: string };
};
/** Why the recipient gets a Craftvia notification — controls the footer line. */
@@ -97,6 +103,9 @@ export const CRAFTVIA_TEMPLATE_KEYS = [
/** Customer-facing Craftvia mails (lane L11) — separate list, recipients are external customers. */
export const CUSTOMER_TEMPLATE_KEYS = ["craftvia_report_customer"] as const satisfies readonly TemplateKey[];
/** L15 Testphase: Anmeldung (Double-Opt-in), Hinweis bei bestehendem Konto, Erinnerungen, Ablauf, Löschung. */
export const TRIAL_TEMPLATE_KEYS = ["trial_confirm", "trial_existing_account", "trial_reminder", "trial_expired", "trial_deletion_notice"] as const satisfies readonly TemplateKey[];
/** Abmelde-/Präferenzhinweis — nur für Benachrichtigungen, nie für Transaktionsmails. */
const FOOTER_NOTE: Record<Locale, string> = {
de: "Sie erhalten diese Benachrichtigung aufgrund Ihrer Rolle in Ihrem Betrieb. Die Einstellungen dazu finden Sie in Ihrem Profil.",
@@ -307,6 +316,130 @@ const customerEn: { [K in CustomerKey]: Builder<K> } = {
}),
};
// ---- L15 Testphase ----
type TrialKey = (typeof TRIAL_TEMPLATE_KEYS)[number];
const contactDe = (c?: string) => (c ? `Fragen oder Wunsch nach der Vollversion? Schreiben Sie uns: ${c}` : "Fragen oder Wunsch nach der Vollversion? Antworten Sie einfach auf diese E-Mail.");
const contactEn = (c?: string) => (c ? `Questions or ready for the full version? Write to us: ${c}` : "Questions or ready for the full version? Simply reply to this e-mail.");
const daysDe = (n: number) => (n <= 0 ? "heute" : n === 1 ? "morgen" : `in ${n} Tagen`);
const daysEn = (n: number) => (n <= 0 ? "today" : n === 1 ? "tomorrow" : `in ${n} days`);
const trialDe: { [K in TrialKey]: Builder<K> } = {
trial_confirm: (v) => ({
subject: `${BRAND.name}: Testphase bestätigen`,
heading: "Testphase bestätigen",
paragraphs: [
greetDe(v.name),
`Sie möchten ${BRAND.name} für „${oneLine(v.companyName)}" testen. Ihre Testphase läuft bis ${v.trialEnd}.`,
"Bestätigen Sie Ihre E-Mail-Adresse – danach richten wir Ihren Zugang sofort ein.",
"Haben Sie das nicht angefordert, ignorieren Sie diese E-Mail. Es wird dann nichts angelegt.",
],
action: { label: "Testphase starten", url: v.actionUrl },
note: `Der Link ist bis ${v.expires} gültig und kann nur einmal verwendet werden.`,
}),
trial_existing_account: (v) => ({
subject: `${BRAND.name}: Sie haben bereits einen Zugang`,
heading: "Sie haben bereits einen Zugang",
paragraphs: [
greetDe(v.name),
`für diese E-Mail-Adresse besteht bereits ein Zugang zu ${BRAND.name}. Eine weitere Testphase mit derselben Adresse ist nicht möglich.`,
"Melden Sie sich mit Ihrem bestehenden Zugang an. Passwort vergessen? Dann setzen Sie es über den zweiten Link zurück.",
"Haben Sie das nicht angefordert, ignorieren Sie diese E-Mail.",
],
action: { label: "Zur Anmeldung", url: v.loginUrl },
note: `Passwort zurücksetzen: ${v.resetUrl}`,
}),
trial_reminder: (v) => ({
subject: `${BRAND.name}: Testphase endet ${daysDe(v.daysLeft)}`,
heading: `Ihre Testphase endet ${daysDe(v.daysLeft)}`,
paragraphs: [
greetDe(v.name),
`die Testphase von „${oneLine(v.tenantName)}" läuft bis ${v.endDate}. Danach sind Ihre Daten nur noch lesbar und exportierbar.`,
contactDe(v.contact),
],
action: { label: `${BRAND.name} öffnen`, url: v.actionUrl },
}),
trial_expired: (v) => ({
subject: `${BRAND.name}: Testphase abgelaufen`,
heading: "Testphase abgelaufen – nur Lesezugriff",
paragraphs: [
greetDe(v.name),
`die Testphase von „${oneLine(v.tenantName)}" ist am ${v.endDate} abgelaufen. Sie können sich weiter anmelden, Daten ansehen und exportieren – Änderungen sind nicht mehr möglich.`,
...(v.deletionDate ? [`Ohne Umwandlung in die Vollversion werden die Daten am ${v.deletionDate} gelöscht.`] : []),
contactDe(v.contact),
],
action: { label: "Daten exportieren", url: v.exportUrl },
}),
trial_deletion_notice: (v) => ({
subject: `${BRAND.name}: Daten werden am ${v.deletionDate} gelöscht`,
heading: "Löschung Ihrer Testdaten",
paragraphs: [
greetDe(v.name),
`die Daten von „${oneLine(v.tenantName)}" werden am ${v.deletionDate} endgültig gelöscht. Exportieren Sie vorher alles, was Sie behalten möchten.`,
contactDe(v.contact),
],
action: { label: "Daten exportieren", url: v.exportUrl },
}),
};
const trialEn: { [K in TrialKey]: Builder<K> } = {
trial_confirm: (v) => ({
subject: `${BRAND.name}: confirm your trial`,
heading: "Confirm your trial",
paragraphs: [
greetEn(v.name),
`you would like to try ${BRAND.name} for "${oneLine(v.companyName)}". Your trial runs until ${v.trialEnd}.`,
"Confirm your e-mail address – we set up your account right away.",
"If you did not request this, ignore this e-mail. Nothing will be created.",
],
action: { label: "Start trial", url: v.actionUrl },
note: `The link is valid until ${v.expires} and can only be used once.`,
}),
trial_existing_account: (v) => ({
subject: `${BRAND.name}: you already have an account`,
heading: "You already have an account",
paragraphs: [
greetEn(v.name),
`there already is a ${BRAND.name} account for this e-mail address. Another trial with the same address is not possible.`,
"Sign in with your existing account. Forgot your password? Reset it with the second link.",
"If you did not request this, ignore this e-mail.",
],
action: { label: "Sign in", url: v.loginUrl },
note: `Reset password: ${v.resetUrl}`,
}),
trial_reminder: (v) => ({
subject: `${BRAND.name}: trial ends ${daysEn(v.daysLeft)}`,
heading: `Your trial ends ${daysEn(v.daysLeft)}`,
paragraphs: [
greetEn(v.name),
`the trial of "${oneLine(v.tenantName)}" runs until ${v.endDate}. Afterwards your data can only be viewed and exported.`,
contactEn(v.contact),
],
action: { label: `Open ${BRAND.name}`, url: v.actionUrl },
}),
trial_expired: (v) => ({
subject: `${BRAND.name}: trial expired`,
heading: "Trial expired – read-only access",
paragraphs: [
greetEn(v.name),
`the trial of "${oneLine(v.tenantName)}" expired on ${v.endDate}. You can still sign in, view and export your data – changes are no longer possible.`,
...(v.deletionDate ? [`Unless converted to the full version, the data will be deleted on ${v.deletionDate}.`] : []),
contactEn(v.contact),
],
action: { label: "Export data", url: v.exportUrl },
}),
trial_deletion_notice: (v) => ({
subject: `${BRAND.name}: data will be deleted on ${v.deletionDate}`,
heading: "Deletion of your trial data",
paragraphs: [
greetEn(v.name),
`the data of "${oneLine(v.tenantName)}" will be deleted permanently on ${v.deletionDate}. Export everything you want to keep beforehand.`,
contactEn(v.contact),
],
action: { label: "Export data", url: v.exportUrl },
}),
};
const de: { [K in TemplateKey]: Builder<K> } = {
invitation: (v) => ({
subject: `Ihr Zugang zu ${BRAND.name}`,
@@ -385,6 +518,7 @@ const de: { [K in TemplateKey]: Builder<K> } = {
}),
...craftviaDe,
...customerDe,
...trialDe,
};
const en: { [K in TemplateKey]: Builder<K> } = {
@@ -465,6 +599,7 @@ const en: { [K in TemplateKey]: Builder<K> } = {
}),
...craftviaEn,
...customerEn,
...trialEn,
};
const CATALOG: Record<Locale, { [K in TemplateKey]: Builder<K> }> = { de, en };