- 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>
175 lines
8.4 KiB
TypeScript
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;
|
|
}
|