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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user