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 { 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, opts: { now?: Date; invite?: (input: Parameters[0]) => Promise } = {}, ): Promise { 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; }