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
@@ -0,0 +1,83 @@
-- L15 Testphase & Onboarding: Testphasen-Lebenszyklus am Mandanten, Onboarding-Checkliste,
-- Selbstanmeldungen (Plattform-Tabelle, ohne RLS) und Mandanten-Datenexporte (RLS).
-- CreateEnum
CREATE TYPE "TenantPlan" AS ENUM ('FULL', 'TRIAL');
-- AlterTable
ALTER TABLE "tenant_settings" ADD COLUMN "onboarding" JSONB NOT NULL DEFAULT '{}';
-- AlterTable
ALTER TABLE "tenants" ADD COLUMN "converted_at" TIMESTAMP(3),
ADD COLUMN "deletion_due_at" TIMESTAMP(3),
ADD COLUMN "plan" "TenantPlan" NOT NULL DEFAULT 'FULL',
ADD COLUMN "read_only_since" TIMESTAMP(3),
ADD COLUMN "trial_deleted_at" TIMESTAMP(3),
ADD COLUMN "trial_deletion_notice_at" TIMESTAMP(3),
ADD COLUMN "trial_ends_at" TIMESTAMP(3),
ADD COLUMN "trial_expired_notice_at" TIMESTAMP(3),
ADD COLUMN "trial_reminder_1_at" TIMESTAMP(3),
ADD COLUMN "trial_reminder_3_at" TIMESTAMP(3),
ADD COLUMN "trial_reminder_7_at" TIMESTAMP(3),
ADD COLUMN "trial_source" TEXT,
ADD COLUMN "trial_started_at" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "trial_signups" (
"id" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"company_name" TEXT NOT NULL,
"sector" TEXT,
"company_size" TEXT,
"admin_name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"trial_end_date" TEXT NOT NULL,
"sample_data" BOOLEAN NOT NULL DEFAULT true,
"modules" TEXT[] DEFAULT ARRAY[]::TEXT[],
"locale" TEXT NOT NULL DEFAULT 'de',
"token_hash" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"ip_hash" TEXT,
"accepted_terms_at" TIMESTAMP(3) NOT NULL,
"confirmed_at" TIMESTAMP(3),
"provisioned_tenant_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "trial_signups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenant_exports" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"requested_by_id" TEXT,
"status" TEXT NOT NULL DEFAULT 'queued',
"storage_key" TEXT,
"file_name" TEXT,
"bytes" INTEGER,
"summary" JSONB,
"error" TEXT,
"expires_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tenant_exports_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "trial_signups_token_hash_key" ON "trial_signups"("token_hash");
-- CreateIndex
CREATE INDEX "trial_signups_email_idx" ON "trial_signups"("email");
-- CreateIndex
CREATE INDEX "trial_signups_status_expires_at_idx" ON "trial_signups"("status", "expires_at");
-- CreateIndex
CREATE INDEX "tenant_exports_tenant_id_created_at_idx" ON "tenant_exports"("tenant_id", "created_at");
-- Mandanten-Tabelle: Row Level Security (docs/craftvia/MIGRATIONS.md)
SELECT enable_tenant_rls('tenant_exports');
+78
View File
@@ -44,6 +44,23 @@ model Tenant {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// L15 Testphase: Lebenszyklus (Plattform-Daten, keine Mandanten-RLS)
plan TenantPlan @default(FULL)
trialSource String? @map("trial_source") // self_signup | platform
trialStartedAt DateTime? @map("trial_started_at")
/// Exklusives Ende: Beginn des Folgetags des gewählten Enddatums (Europe/Berlin)
trialEndsAt DateTime? @map("trial_ends_at")
convertedAt DateTime? @map("converted_at")
readOnlySince DateTime? @map("read_only_since")
/// null = keine automatische Löschung vorgemerkt
deletionDueAt DateTime? @map("deletion_due_at")
trialDeletedAt DateTime? @map("trial_deleted_at")
trialReminder7At DateTime? @map("trial_reminder_7_at")
trialReminder3At DateTime? @map("trial_reminder_3_at")
trialReminder1At DateTime? @map("trial_reminder_1_at")
trialExpiredNoticeAt DateTime? @map("trial_expired_notice_at")
trialDeletionNoticeAt DateTime? @map("trial_deletion_notice_at")
users User[]
roles Role[]
auditLogs AuditLog[]
@@ -79,6 +96,8 @@ model TenantSettings {
lotseAddressForm String? @map("lotse_address_form")
// L10b (Spec §31): monthly AI token budget (input + output) per tenant; null = env AI_MONTHLY_TOKEN_LIMIT, 0 = unlimited
aiMonthlyTokenLimit Int? @map("ai_monthly_token_limit")
// L15: „Erste Schritte"-Checkliste ({ done: string[], hidden: boolean })
onboarding Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -1433,3 +1452,62 @@ model BillingRecord {
@@index([tenantId, workOrderId])
@@map("billing_records")
}
// ── L15 Testphase & Onboarding ─────────────────────────────────────────────────
enum TenantPlan {
FULL
TRIAL
}
/// Selbstanmeldung „Kostenlos testen" vor der E-Mail-Bestätigung (Plattform-Tabelle, kein tenant_id).
/// Keine Klartext-Passwörter/Token: Argon2id-Hash (+ Pepper) bzw. SHA-256 des Tokens; IP nur als HMAC.
model TrialSignup {
id String @id @default(cuid())
/// pending | confirmed | superseded | expired | existing_account | failed
status String @default("pending")
companyName String @map("company_name")
sector String?
companySize String? @map("company_size")
adminName String @map("admin_name")
email String
/// Wird nach der Bestätigung geleert.
passwordHash String @map("password_hash")
/// Gewähltes Enddatum YYYY-MM-DD (Europe/Berlin)
trialEndDate String @map("trial_end_date")
sampleData Boolean @default(true) @map("sample_data")
modules String[] @default([])
locale String @default("de")
tokenHash String @unique @map("token_hash")
expiresAt DateTime @map("expires_at")
ipHash String? @map("ip_hash")
acceptedTermsAt DateTime @map("accepted_terms_at")
confirmedAt DateTime? @map("confirmed_at")
provisionedTenantId String? @map("provisioned_tenant_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([email])
@@index([status, expiresAt])
@@map("trial_signups")
}
/// Daten-Export eines Mandanten (ZIP mit CSV/JSON + Dateien), auch im Nur-Lesen-Zustand.
model TenantExport {
id String @id @default(cuid())
tenantId String @map("tenant_id")
requestedById String? @map("requested_by_id")
/// queued | running | done | failed
status String @default("queued")
storageKey String? @map("storage_key")
fileName String? @map("file_name")
bytes Int?
summary Json?
error String?
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([tenantId, createdAt])
@@map("tenant_exports")
}