Files
craftvia/src/server/services/trial/admin.ts
T
msolarczekandClaude Opus 5 d9290a187c 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>
2026-09-15 19:01:47 +02:00

175 lines
8.4 KiB
TypeScript

import { z } from "zod";
import { prisma } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { issueToken } from "@/server/auth-token";
import { sendUserInvitationMail } from "@/server/auth-selfservice";
import { ServiceError } from "@/server/services/context";
import { addDaysToKey, isDateKey, todayKey, trialEndInstant } from "@/lib/trial/dates";
import { daysMs, PLATFORM_TRIAL_MAX_DAYS, TRIAL_DELETION_GRACE_DAYS, TRIAL_DELETION_NOTICE_DAYS } from "./config";
import { provisionTrialTenant, type ProvisionTrialResult } from "./provision";
/**
* L15 Testphase: platform-admin operations on trial tenants. ONLY for active platform full admins —
* every function re-checks the actor against the PlatformAdmin store (tenant administrators have no
* PlatformAdmin row and are rejected with `forbidden`). The adapter additionally requires the
* platform session (src/server/actions/trial-platform.ts). Every change → audit (scope platform,
* attached to the tenant so it shows in the tenant's audit trail) with before/after.
*/
export type PlatformActor = { platformAdminId: string };
async function assertPlatformFullAdmin(actor: PlatformActor): Promise<void> {
const admin = actor.platformAdminId
? await prisma.platformAdmin.findUnique({ where: { id: actor.platformAdminId }, select: { status: true, role: true } })
: null;
if (!admin || admin.status !== "ACTIVE" || admin.role !== "full") throw new ServiceError("forbidden", "platform_admin_required");
}
const LIFECYCLE_SELECT = {
id: true,
name: true,
status: true,
plan: true,
trialEndsAt: true,
convertedAt: true,
readOnlySince: true,
deletionDueAt: true,
trialDeletedAt: true,
} as const;
async function loadTrialTenant(tenantId: string) {
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: LIFECYCLE_SELECT });
if (!tenant) throw new ServiceError("not_found", "tenant not found");
if (tenant.trialDeletedAt || tenant.status === "ARCHIVED") throw new ServiceError("invalid", "tenant_deleted");
if (tenant.plan !== "TRIAL" || !tenant.trialEndsAt) throw new ServiceError("invalid", "not_a_trial");
return tenant;
}
type Snapshot = { plan: string; trialEndsAt: Date | null; readOnlySince: Date | null; deletionDueAt: Date | null; convertedAt: Date | null };
const snap = (t: Snapshot) => ({ plan: t.plan, trialEndsAt: t.trialEndsAt, readOnlySince: t.readOnlySince, deletionDueAt: t.deletionDueAt, convertedAt: t.convertedAt });
async function audit(actor: PlatformActor, tenantId: string, entity: string, before: Snapshot, after: Snapshot) {
await writeAuditLog({ tenantId, scope: "platform", actorId: actor.platformAdminId, action: "update", entity, entityId: tenantId, before: snap(before), after: snap(after) });
}
const RESET_NOTICES = { trialReminder7At: null, trialReminder3At: null, trialReminder1At: null, trialExpiredNoticeAt: null, trialDeletionNoticeAt: null } as const;
export const platformTrialSchema = z.object({
companyName: z.string().trim().min(2).max(120),
sector: z.string().trim().max(80).default(""),
adminName: z.string().trim().min(2).max(120),
adminEmail: z.string().trim().toLowerCase().max(200).regex(/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/),
endDate: z.string().refine(isDateKey, "date_invalid"),
sampleData: z.boolean().default(true),
});
function assertEndDateRange(endDateKey: string, now: Date) {
if (!isDateKey(endDateKey)) throw new ServiceError("invalid", "date_invalid", { field: "endDate" });
const today = todayKey(now);
if (endDateKey < today) throw new ServiceError("invalid", "date_in_past", { field: "endDate" });
if (endDateKey > addDaysToKey(today, PLATFORM_TRIAL_MAX_DAYS)) throw new ServiceError("invalid", "date_too_late", { field: "endDate" });
}
/** Wizard "Testmandant anlegen": provisioning + invitation of a NEW admin identity (existing people keep their access). */
export async function createPlatformTrialTenant(
actor: PlatformActor,
raw: z.input<typeof platformTrialSchema>,
opts: { now?: Date; invite?: (input: Parameters<typeof sendUserInvitationMail>[0]) => Promise<unknown> } = {},
): Promise<ProvisionTrialResult & { invited: boolean }> {
await assertPlatformFullAdmin(actor);
const now = opts.now ?? new Date();
const input = platformTrialSchema.parse(raw);
assertEndDateRange(input.endDate, now);
const result = await provisionTrialTenant({
companyName: input.companyName,
sector: input.sector,
admin: { name: input.adminName, email: input.adminEmail },
endDateKey: input.endDate,
sampleData: input.sampleData,
source: "platform",
actorId: actor.platformAdminId,
now,
});
let invited = false;
if (result.identityCreated) {
const { raw: token, expiresAt } = await issueToken({ principalType: "identity", principalId: result.identityId, tenantId: result.tenantId, type: "invitation" });
await (opts.invite ?? sendUserInvitationMail)({ to: input.adminEmail, name: input.adminName, tenantId: result.tenantId, tenantName: input.companyName, rawToken: token, expiresAt });
invited = true;
}
return { ...result, invited };
}
/** Change/extend the end date — also after expiry (the tenant becomes writable again). */
export async function changeTrialEndDate(actor: PlatformActor, tenantId: string, endDateKey: string, opts: { now?: Date } = {}) {
await assertPlatformFullAdmin(actor);
const now = opts.now ?? new Date();
assertEndDateRange(endDateKey, now);
const before = await loadTrialTenant(tenantId);
const endsAt = trialEndInstant(endDateKey);
const after = await prisma.tenant.update({
where: { id: tenantId },
data: {
trialEndsAt: endsAt,
readOnlySince: null,
// a cancelled deletion stays cancelled; otherwise the grace period follows the new end
deletionDueAt: before.deletionDueAt ? new Date(endsAt.getTime() + daysMs(TRIAL_DELETION_GRACE_DAYS)) : null,
...RESET_NOTICES,
},
select: LIFECYCLE_SELECT,
});
await audit(actor, tenantId, "trial_end_date", before, after);
return after;
}
/** Convert to the full version: never deleted, never read-only again. */
export async function convertTenantToFull(actor: PlatformActor, tenantId: string, opts: { now?: Date } = {}) {
await assertPlatformFullAdmin(actor);
const before = await loadTrialTenant(tenantId);
const after = await prisma.tenant.update({
where: { id: tenantId },
data: { plan: "FULL", convertedAt: opts.now ?? new Date(), readOnlySince: null, deletionDueAt: null, trialDeletionNoticeAt: null },
select: LIFECYCLE_SELECT,
});
await audit(actor, tenantId, "trial_converted", before, after);
return after;
}
/** End the trial immediately (read-only from now on). */
export async function endTrialNow(actor: PlatformActor, tenantId: string, opts: { now?: Date } = {}) {
await assertPlatformFullAdmin(actor);
const now = opts.now ?? new Date();
const before = await loadTrialTenant(tenantId);
const after = await prisma.tenant.update({
where: { id: tenantId },
data: {
trialEndsAt: now,
readOnlySince: now,
deletionDueAt: before.deletionDueAt ? new Date(now.getTime() + daysMs(TRIAL_DELETION_GRACE_DAYS)) : null,
trialExpiredNoticeAt: null,
trialDeletionNoticeAt: null,
},
select: LIFECYCLE_SELECT,
});
await audit(actor, tenantId, "trial_ended", before, after);
return after;
}
/** Schedule the automatic deletion (end + 30 days, at least TRIAL_DELETION_NOTICE_DAYS from now so the notice can go out). */
export async function scheduleTrialDeletion(actor: PlatformActor, tenantId: string, opts: { now?: Date } = {}) {
await assertPlatformFullAdmin(actor);
const now = opts.now ?? new Date();
const before = await loadTrialTenant(tenantId);
const due = Math.max(before.trialEndsAt!.getTime() + daysMs(TRIAL_DELETION_GRACE_DAYS), now.getTime() + daysMs(TRIAL_DELETION_NOTICE_DAYS));
const after = await prisma.tenant.update({ where: { id: tenantId }, data: { deletionDueAt: new Date(due), trialDeletionNoticeAt: null }, select: LIFECYCLE_SELECT });
await audit(actor, tenantId, "trial_deletion_scheduled", before, after);
return after;
}
export async function cancelTrialDeletion(actor: PlatformActor, tenantId: string) {
await assertPlatformFullAdmin(actor);
const before = await loadTrialTenant(tenantId);
const after = await prisma.tenant.update({ where: { id: tenantId }, data: { deletionDueAt: null, trialDeletionNoticeAt: null }, select: LIFECYCLE_SELECT });
await audit(actor, tenantId, "trial_deletion_cancelled", before, after);
return after;
}