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:
@@ -26,6 +26,7 @@ import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
|
||||
import { getDashboardTiles } from "@/server/services/work-orders/dashboard";
|
||||
import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options";
|
||||
import { listOrderTypes } from "@/server/services/work-orders/settings";
|
||||
import { GettingStarted } from "@/components/trial/getting-started";
|
||||
|
||||
type SP = Record<string, string | string[] | undefined>;
|
||||
|
||||
@@ -65,6 +66,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("title")} sub={t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug ?? "" })} />
|
||||
<GettingStarted ctx={ctx} welcome={sp.welcome === "1"} /> {/* L15 Testphase */}
|
||||
{sp.module === "disabled" && (
|
||||
<p role="status" className="mb-4 rounded-lg border border-[var(--warn)] bg-card px-4 py-3 text-sm text-[var(--warn)]">
|
||||
{t("moduleDisabled")}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { assertSameOrigin, requireApiContext } from "@/server/api/context";
|
||||
import { ApiError, toErrorResponse } from "@/server/api/respond";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { storeFile } from "@/server/services/documents/store";
|
||||
import { assertTenantWritable } from "@/server/services/trial/state";
|
||||
|
||||
/**
|
||||
* Multipart upload for the backoffice document tabs (customer, site, /documents).
|
||||
@@ -19,6 +20,7 @@ export async function POST(req: Request) {
|
||||
try {
|
||||
assertSameOrigin(req);
|
||||
const ctx = await requireApiContext("documents");
|
||||
await assertTenantWritable(ctx.tenantId); // L15: expired trial → read-only
|
||||
|
||||
let form: FormData;
|
||||
try {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
||||
import { NotificationBell } from "@/components/notifications/bell";
|
||||
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
|
||||
import { BackofficeFrame } from "@/components/backoffice-frame";
|
||||
import { TrialBanner } from "@/components/trial/trial-banner";
|
||||
|
||||
export default async function AppLayout({
|
||||
children,
|
||||
@@ -111,6 +112,8 @@ export default async function AppLayout({
|
||||
</form>
|
||||
</>
|
||||
}>
|
||||
{/* L15 Testphase: Countdown ab 7 Tagen bzw. Nur-Lesen-Hinweis */}
|
||||
<TrialBanner tenantId={session.user.tenantId} variant="backoffice" canExport={(session.user.permissions ?? []).includes("tenant:manage")} />
|
||||
{children}
|
||||
</BackofficeFrame>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { toErrorResponse } from "@/server/api/respond";
|
||||
import { openTenantExport } from "@/server/services/trial/export";
|
||||
|
||||
/**
|
||||
* GET /settings/export/<id> — download of a finished tenant data export (L15 Testphase).
|
||||
* Session + DB-authoritative `tenant:manage`, tenant-bound row; a read, therefore also allowed for
|
||||
* an expired (read-only) trial tenant.
|
||||
*/
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const ctx = await requireApiContext(null, "tenant:manage");
|
||||
const { id } = await params;
|
||||
const { bytes, fileName } = await openTenantExport(ctx, id);
|
||||
return new Response(new Uint8Array(bytes), {
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="${fileName.replace(/[^\w.\-]/g, "_")}"`,
|
||||
"Content-Length": String(bytes.length),
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return toErrorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { Download, PackageOpen } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatInstantDate } from "@/lib/trial/dates";
|
||||
import { requestExportAction } from "@/server/actions/trial-tenant";
|
||||
import { listTenantExports } from "@/server/services/trial/export";
|
||||
|
||||
const TONE = { queued: "warn", running: "warn", done: "ok", failed: "mut", expired: "mut" } as const;
|
||||
|
||||
/** L15 Testphase: data export for tenant administrators (also available in the read-only state). */
|
||||
export default async function DataExportPage({ searchParams }: { searchParams: Promise<{ requested?: string; error?: string }> }) {
|
||||
const session = await requireSession();
|
||||
if (!hasPermission(session, "tenant:manage")) redirect("/dashboard");
|
||||
const sp = await searchParams;
|
||||
const [t, locale] = await Promise.all([getTranslations("trial.export"), getLocale()]);
|
||||
const ctx = { db: dbForTenant(session.user.tenantId), tenantId: session.user.tenantId, userId: session.user.id, permissions: new Set(session.user.permissions ?? []) };
|
||||
const exports = await listTenantExports(ctx);
|
||||
const errorKey = sp.error === "export_running" ? "export_running" : "failed";
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
|
||||
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<PackageOpen className="size-6 shrink-0 text-[var(--ui-primary)]" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm">{t("contents")}</p>
|
||||
<p className="mt-1 text-[12.5px] text-muted-foreground">{t("readOnlyHint")}</p>
|
||||
</div>
|
||||
<form action={requestExportAction}>
|
||||
<Button type="submit" className="h-11">{t("request")}</Button>
|
||||
</form>
|
||||
</div>
|
||||
{sp.requested && <p role="status" className="mt-3 rounded-lg bg-[rgba(57,192,127,0.14)] px-3 py-2 text-sm text-[var(--ok)]">{t("requested")}</p>}
|
||||
{sp.error && <p role="alert" className="mt-3 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t(`errors.${errorKey}`)}</p>}
|
||||
</div>
|
||||
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-5">
|
||||
<h2 className="font-heading text-[15px] font-semibold">{t("listTitle")}</h2>
|
||||
{exports.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
<ul className="mt-2 divide-y">
|
||||
{exports.map((e) => {
|
||||
const status = e.expired ? "expired" : (e.status as keyof typeof TONE);
|
||||
return (
|
||||
<li key={e.id} className="flex flex-wrap items-center gap-3 py-2.5 text-sm">
|
||||
<Pill tone={TONE[status] ?? "mut"}>{t(`status.${status}`)}</Pill>
|
||||
<span className="text-muted-foreground">{t("created")}: {e.createdAt.toLocaleString(locale === "en" ? "en-GB" : "de-DE", { timeZone: "Europe/Berlin" })}</span>
|
||||
{e.bytes != null && <span className="text-muted-foreground">{(e.bytes / 1024 / 1024).toFixed(1)} MB</span>}
|
||||
{e.status === "failed" && e.error && <span className="text-[12.5px] text-[var(--risk)]">{e.error}</span>}
|
||||
{e.status === "done" && !e.expired && (
|
||||
<Link href={`/settings/export/${e.id}`} prefetch={false} className="ml-auto inline-flex min-h-11 items-center gap-1.5 rounded-md px-3 font-semibold text-[var(--ui-primary)] hover:bg-muted">
|
||||
<Download className="size-4" aria-hidden /> {t("download")}
|
||||
{e.expiresAt && <span className="font-normal text-muted-foreground">({t("expires", { date: formatInstantDate(e.expiresAt, locale) })})</span>}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { BottomNav } from "@/components/field/bottom-nav";
|
||||
import { OnlineBadge } from "@/components/field/online-badge";
|
||||
import { RunningClockBar, type ClockSession } from "@/components/field/running-clock-bar";
|
||||
import { OfflineRuntime } from "@/components/offline/offline-runtime";
|
||||
import { TrialBanner } from "@/components/trial/trial-banner";
|
||||
|
||||
/** L12: own running/paused session + open approvals for the shell (never blocks the page). */
|
||||
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number }> {
|
||||
@@ -53,6 +54,8 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
|
||||
<OnlineBadge />
|
||||
</div>
|
||||
</header>
|
||||
{/* L15 Testphase: Countdown ab 7 Tagen bzw. Nur-Lesen-Hinweis */}
|
||||
<TrialBanner tenantId={access.session.user.tenantId} variant="mobile" />
|
||||
<div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div>
|
||||
<RunningClockBar initial={time.clock} />
|
||||
<BottomNav approvals={time.approvals} />
|
||||
|
||||
@@ -19,6 +19,7 @@ import { UserTable } from "@/components/user-table";
|
||||
import { UserCreateForm, UserEditForm } from "@/components/user-forms";
|
||||
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
|
||||
import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel";
|
||||
import { TrialAdminCard } from "@/components/trial/trial-admin-card";
|
||||
|
||||
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
|
||||
|
||||
@@ -27,7 +28,7 @@ export default async function AdminTenantPage({
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string }>;
|
||||
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; trial?: string; invited?: string; trialDone?: string }>;
|
||||
}) {
|
||||
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
|
||||
const { id } = await params;
|
||||
@@ -216,6 +217,15 @@ export default async function AdminTenantPage({
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* L15 Testphase: Enddatum, Umwandlung, Beenden, Löschung (Bestätigung + Plattform-Audit) */}
|
||||
<TrialAdminCard
|
||||
tenant={tenant}
|
||||
isFullAdmin={isFullAdmin}
|
||||
base={base}
|
||||
op={sp.trial === "created" ? undefined : sp.trial}
|
||||
notice={sp.trial === "created" ? (sp.invited ? "invited" : "created") : sp.trialDone ? "done" : null}
|
||||
/>
|
||||
|
||||
{/* Lebenszyklus */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-3 font-heading text-sm font-semibold">{t("lifecycleTitle")}</p>
|
||||
|
||||
@@ -16,16 +16,22 @@ import {
|
||||
import { createTenant } from "@/server/actions/admin";
|
||||
import { getMailStatus } from "@/server/actions/mail";
|
||||
import { MailStatusPanel } from "@/components/mail-status-panel";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { TrialBadge } from "@/components/trial/trial-badge";
|
||||
|
||||
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
|
||||
const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", SUSPENDED: "Gesperrt", ARCHIVED: "Archiviert" };
|
||||
|
||||
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string }> }) {
|
||||
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string; plan?: string }> }) {
|
||||
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
|
||||
const params = await searchParams;
|
||||
// L15 Testphase: Filter Test/Voll
|
||||
const tt = await getTranslations("trial.platform");
|
||||
const planFilter = params.plan === "trial" ? "TRIAL" : params.plan === "full" ? "FULL" : null;
|
||||
|
||||
const [tenants, mailStatus] = await Promise.all([
|
||||
prisma.tenant.findMany({
|
||||
where: planFilter ? { plan: planFilter } : undefined,
|
||||
include: { _count: { select: { users: true } }, modules: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
@@ -42,6 +48,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
||||
actions={
|
||||
<span className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/admins" />}>Administratoren</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/admin/trial" />}>{tt("newTrial")}</Button>
|
||||
<Button nativeButton={false} render={<Link href={params.new ? "/admin" : "/admin?new=1"} />}>
|
||||
<Plus className="size-4" /> Neuer Kunde
|
||||
</Button>
|
||||
@@ -92,6 +99,23 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav aria-label={tt("filter")} className="mt-4 flex flex-wrap gap-2">
|
||||
{[
|
||||
{ key: null, href: "/admin", label: tt("filterAll") },
|
||||
{ key: "TRIAL", href: "/admin?plan=trial", label: tt("filterTrial") },
|
||||
{ key: "FULL", href: "/admin?plan=full", label: tt("filterFull") },
|
||||
].map((f) => (
|
||||
<Link
|
||||
key={f.href}
|
||||
href={f.href}
|
||||
aria-current={planFilter === f.key ? "page" : undefined}
|
||||
className={`inline-flex min-h-11 items-center rounded-full border px-4 text-[13px] font-semibold ${planFilter === f.key ? "border-[var(--ui-primary)] bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]" : "text-muted-foreground hover:bg-muted"}`}
|
||||
>
|
||||
{f.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -99,6 +123,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
||||
<TableHead>Kunde</TableHead>
|
||||
<TableHead>Kürzel</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>{tt("colPlan")}</TableHead>
|
||||
<TableHead>Nutzer</TableHead>
|
||||
<TableHead>Aktive Module</TableHead>
|
||||
</TableRow>
|
||||
@@ -114,6 +139,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{t.slug}</TableCell>
|
||||
<TableCell><Pill tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Pill></TableCell>
|
||||
<TableCell><TrialBadge tenant={t} /></TableCell>
|
||||
<TableCell className="text-muted-foreground">{t._count.users}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{active > 0 ? `${active} Module` : "—"}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { PlatformTrialCreateForm } from "@/components/trial/platform-forms";
|
||||
import { addDaysToKey, todayKey } from "@/lib/trial/dates";
|
||||
import { PLATFORM_TRIAL_MAX_DAYS, TRIAL_DEFAULT_DAYS } from "@/server/services/trial/config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** L15 Testphase: platform wizard "Testmandant anlegen" (access: (platform)/layout.tsx; action: full admins). */
|
||||
export default async function PlatformTrialWizardPage() {
|
||||
const t = await getTranslations("trial.platform.wizard");
|
||||
const ta = await getTranslations("admin");
|
||||
const today = todayKey();
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<Link href="/admin" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" /> {ta("backToOverview")}
|
||||
</Link>
|
||||
<div className="mt-2 max-w-3xl">
|
||||
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
|
||||
<PlatformTrialCreateForm defaultEnd={addDaysToKey(today, TRIAL_DEFAULT_DAYS)} min={today} max={addDaysToKey(today, PLATFORM_TRIAL_MAX_DAYS)} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { assertSameOrigin, requireApiContext } from "@/server/api/context";
|
||||
import { ApiError, json, readFormData, toErrorResponse } from "@/server/api/respond";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { uploadWorkOrderDocument } from "@/server/services/work-orders/documents";
|
||||
import { assertTenantWritable } from "@/server/services/trial/state";
|
||||
|
||||
/**
|
||||
* POST /api/v1/work-orders/[id]/documents — multipart upload (file, category, visibility, title?).
|
||||
@@ -17,6 +18,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
||||
try {
|
||||
assertSameOrigin(req);
|
||||
const ctx = await requireApiContext("work_orders", "document:write");
|
||||
await assertTenantWritable(ctx.tenantId); // L15: not wrapped in withApi → explicit trial write lock
|
||||
const form = await readFormData(req);
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File) || file.size === 0) throw new ServiceError("invalid", "file_missing");
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { TrialConfirmForm } from "@/components/trial/confirm-form";
|
||||
import { formatDateKey } from "@/lib/trial/dates";
|
||||
import { peekTrialSignup } from "@/server/services/trial/signup";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("trial.meta");
|
||||
return { title: t("confirmTitle") };
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* L15 Testphase: landing page of the confirmation link. Shows the pending signup (link is only
|
||||
* checked, not consumed); provisioning happens on the POST of the button (TrialConfirmForm).
|
||||
*/
|
||||
export default async function TrialConfirmPage({ searchParams }: { searchParams: Promise<{ token?: string }> }) {
|
||||
const { token = "" } = await searchParams;
|
||||
const [t, locale] = await Promise.all([getTranslations("trial.confirm"), getLocale()]);
|
||||
const signup = await peekTrialSignup(token);
|
||||
|
||||
return (
|
||||
<div className="shadow-card mt-4 w-full max-w-md self-start rounded-2xl border bg-card p-6 sm:p-8">
|
||||
<h1 className="font-heading text-xl font-semibold">{t("title")}</h1>
|
||||
{signup ? (
|
||||
<>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("intro")}</p>
|
||||
<dl className="mt-4 divide-y rounded-lg border text-sm">
|
||||
<div className="flex justify-between gap-3 px-3 py-2"><dt className="text-muted-foreground">{t("company")}</dt><dd className="text-right font-medium">{signup.companyName}</dd></div>
|
||||
<div className="flex justify-between gap-3 px-3 py-2"><dt className="text-muted-foreground">{t("until")}</dt><dd className="font-medium">{formatDateKey(signup.trialEndDate, locale)}</dd></div>
|
||||
<div className="flex justify-between gap-3 px-3 py-2"><dt className="text-muted-foreground">{t("sampleData")}</dt><dd className="font-medium">{signup.sampleData ? t("yes") : t("no")}</dd></div>
|
||||
</dl>
|
||||
<TrialConfirmForm token={token} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p role="alert" className="mt-4 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t("invalid")}</p>
|
||||
<div className="mt-4 flex flex-col gap-1 text-center">
|
||||
<Link href="/testen" className="flex min-h-11 items-center justify-center text-sm font-semibold text-[var(--ui-primary)]">{t("toSignup")}</Link>
|
||||
<Link href="/login" className="flex min-h-11 items-center justify-center text-sm text-muted-foreground">{t("toLogin")}</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { LegalPlaceholder } from "@/components/trial/legal-placeholder";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("trial.meta");
|
||||
return { title: t("privacyTitle") };
|
||||
}
|
||||
|
||||
/** L15 Testphase: placeholder privacy notice (operator replaces the text before go-live). */
|
||||
export default async function TrialPrivacyPage() {
|
||||
const t = await getTranslations("trial");
|
||||
return <LegalPlaceholder title={t("meta.privacyTitle")} paragraphs={[t("legal.privacyBody1"), t("legal.privacyBody2")]} placeholder={t("legal.placeholder")} back={t("legal.back")} />;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
||||
|
||||
/** L15 Testphase: public shell of /testen (no session; see PUBLIC_PATHS in src/proxy.ts). */
|
||||
export default async function TrialPublicLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
const t = await getTranslations("trial.public");
|
||||
return (
|
||||
<div className="flex flex-1 flex-col bg-background">
|
||||
<header className="flex items-center justify-between gap-3 px-4 py-4 sm:px-8">
|
||||
<Link href="/testen" className="flex min-h-11 items-center" aria-label="Craftvia">
|
||||
<CraftviaLogo variant="horizontal" height={32} />
|
||||
</Link>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<span className="hidden sm:inline">{t("haveAccount")} </span>
|
||||
<Link href="/login" className="inline-flex min-h-11 items-center font-semibold text-[var(--ui-primary)]">{t("login")}</Link>
|
||||
</p>
|
||||
</header>
|
||||
<main className="flex flex-1 justify-center px-4 pb-10 sm:px-8">{children}</main>
|
||||
<footer className="flex flex-wrap justify-center gap-4 border-t px-4 py-3 text-[12.5px] text-muted-foreground">
|
||||
<Link href="/testen/nutzungsbedingungen" className="inline-flex min-h-11 items-center hover:text-foreground">{t("terms")}</Link>
|
||||
<Link href="/testen/datenschutz" className="inline-flex min-h-11 items-center hover:text-foreground">{t("privacy")}</Link>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { LegalPlaceholder } from "@/components/trial/legal-placeholder";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("trial.meta");
|
||||
return { title: t("termsTitle") };
|
||||
}
|
||||
|
||||
/** L15 Testphase: placeholder terms of use (operator replaces the text before go-live). */
|
||||
export default async function TrialTermsPage() {
|
||||
const t = await getTranslations("trial");
|
||||
return <LegalPlaceholder title={t("meta.termsTitle")} paragraphs={[t("legal.termsBody1"), t("legal.termsBody2")]} placeholder={t("legal.placeholder")} back={t("legal.back")} />;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { TrialSignupWizard } from "@/components/trial/signup-wizard";
|
||||
import { MODULES } from "@/lib/modules";
|
||||
import { DEFAULT_PASSWORD_POLICY, describePasswordPolicy } from "@/lib/password-policy";
|
||||
import { currentTrialBounds } from "@/server/services/trial/signup";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("trial.meta");
|
||||
return { title: t("title") };
|
||||
}
|
||||
|
||||
// the date bounds depend on "today" — never prerender
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** L15 Testphase: public wizard "Kostenlos testen". */
|
||||
export default async function TrialSignupPage() {
|
||||
const [t, tm, locale] = await Promise.all([getTranslations("trial.public"), getTranslations("modules"), getLocale()]);
|
||||
const modules = MODULES.map((m) => ({ key: m.key, label: tm(m.nav) }));
|
||||
|
||||
return (
|
||||
<div className="grid w-full max-w-5xl gap-8 lg:grid-cols-[1fr_1.2fr] lg:items-start">
|
||||
<section className="pt-2 lg:pt-10">
|
||||
<h1 className="font-heading text-[28px] leading-tight font-semibold sm:text-[34px]">{t("heading")}</h1>
|
||||
<p className="mt-3 text-[15px] text-muted-foreground">{t("intro")}</p>
|
||||
<ul className="mt-6 space-y-3">
|
||||
{(["benefit1", "benefit2", "benefit3"] as const).map((k) => (
|
||||
<li key={k} className="flex items-start gap-2.5 text-[14.5px]">
|
||||
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-[var(--ok)]" aria-hidden />
|
||||
{t(k)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<section className="shadow-card relative rounded-2xl border bg-card p-5 sm:p-8">
|
||||
<TrialSignupWizard bounds={currentTrialBounds()} modules={modules} passwordPolicy={describePasswordPolicy(DEFAULT_PASSWORD_POLICY)} locale={locale} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { confirmTrialSignupAction, type TrialConfirmState } from "@/server/actions/trial-signup";
|
||||
|
||||
/** L15 Testphase: confirmation button (POST) — the GET link alone never provisions anything. */
|
||||
export function TrialConfirmForm({ token }: { token: string }) {
|
||||
const t = useTranslations("trial.confirm");
|
||||
const [state, action, pending] = useActionState<TrialConfirmState, FormData>(confirmTrialSignupAction, { status: "idle" });
|
||||
|
||||
if (state.status === "invalid" || state.status === "expired") {
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
<p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t(state.status)}</p>
|
||||
<Link href="/testen" className="flex min-h-11 items-center justify-center text-sm font-semibold text-[var(--ui-primary)]">{t("toSignup")}</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={action} className="mt-5 space-y-3">
|
||||
<input type="hidden" name="token" value={token} />
|
||||
{state.status === "rate_limited" && (
|
||||
<p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t("rate_limited")}</p>
|
||||
)}
|
||||
<Button type="submit" className="h-11 w-full" disabled={pending}>
|
||||
{pending ? t("pending") : t("button")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { CheckCircle2, Circle } from "lucide-react";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { getOnboardingChecklist } from "@/server/services/trial/onboarding";
|
||||
import { getTrialState } from "@/server/services/trial/state";
|
||||
import { hideOnboardingAction, toggleOnboardingItemAction } from "@/server/actions/trial-tenant";
|
||||
|
||||
/** L15 Testphase: "Erste Schritte" card on the dashboard (tenant admins of trial-origin tenants). */
|
||||
export async function GettingStarted({ ctx, welcome = false }: { ctx: ServiceCtx; welcome?: boolean }) {
|
||||
const checklist = await getOnboardingChecklist(ctx);
|
||||
if (!checklist) return null;
|
||||
const [t, trial] = await Promise.all([getTranslations("trial.onboarding"), getTrialState(ctx.tenantId)]);
|
||||
const writable = !trial.readOnly;
|
||||
const percent = Math.round((checklist.completed / checklist.total) * 100);
|
||||
|
||||
return (
|
||||
<section aria-labelledby="getting-started-title" className="shadow-card mb-4 rounded-xl border bg-card p-4 md:p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
{welcome && <p className="mb-1 text-sm font-semibold text-[var(--ok)]">{t("welcome")}</p>}
|
||||
<h2 id="getting-started-title" className="font-heading text-[15px] font-semibold">{t("title")}</h2>
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("progress", { done: checklist.completed, total: checklist.total })}</p>
|
||||
</div>
|
||||
{writable && (
|
||||
<form action={hideOnboardingAction}>
|
||||
<button type="submit" className="min-h-11 rounded-md px-3 text-[13px] font-semibold text-muted-foreground hover:bg-muted hover:text-foreground">{t("hide")}</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-muted" role="progressbar" aria-valuemin={0} aria-valuemax={checklist.total} aria-valuenow={checklist.completed} aria-label={t("progress", { done: checklist.completed, total: checklist.total })}>
|
||||
<div className="h-full rounded-full bg-[var(--ok)]" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<ul className="mt-3 divide-y">
|
||||
{checklist.items.map((item) => (
|
||||
<li key={item.key} className="flex flex-wrap items-center gap-3 py-2.5">
|
||||
{item.done ? <CheckCircle2 className="size-5 shrink-0 text-[var(--ok)]" aria-hidden /> : <Circle className="size-5 shrink-0 text-muted-foreground" aria-hidden />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={item.done ? "text-sm font-semibold text-muted-foreground line-through" : "text-sm font-semibold"}>
|
||||
<span className="sr-only">{item.done ? t("doneLabel") : t("openLabel")}: </span>
|
||||
{t(`items.${item.key}.title`)}
|
||||
</p>
|
||||
<p className="text-[12.5px] text-muted-foreground">{t(`items.${item.key}.text`)}{item.auto ? ` · ${t("auto")}` : ""}</p>
|
||||
</div>
|
||||
<Link href={item.href} className="inline-flex min-h-11 items-center rounded-md px-3 text-[13px] font-semibold text-[var(--ui-primary)] hover:bg-muted">{t("open")}</Link>
|
||||
{writable && !item.auto && (
|
||||
<form action={toggleOnboardingItemAction}>
|
||||
<input type="hidden" name="key" value={item.key} />
|
||||
<input type="hidden" name="done" value={item.done ? "0" : "1"} />
|
||||
<button type="submit" className="min-h-11 rounded-md border px-3 text-[13px] hover:bg-muted">{item.done ? t("markOpen") : t("markDone")}</button>
|
||||
</form>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import Link from "next/link";
|
||||
|
||||
/** L15 Testphase: simple legal text page (terms/privacy placeholders under /testen). */
|
||||
export function LegalPlaceholder({ title, paragraphs, placeholder, back }: { title: string; paragraphs: string[]; placeholder: string; back: string }) {
|
||||
return (
|
||||
<article className="shadow-card mt-4 w-full max-w-2xl self-start rounded-2xl border bg-card p-6 sm:p-8">
|
||||
<h1 className="font-heading text-xl font-semibold">{title}</h1>
|
||||
<p className="mt-2 rounded-lg bg-muted px-3 py-2 text-[13px] text-muted-foreground">{placeholder}</p>
|
||||
{paragraphs.map((p) => (
|
||||
<p key={p} className="mt-3 text-sm leading-relaxed">{p}</p>
|
||||
))}
|
||||
<Link href="/testen" className="mt-4 inline-flex min-h-11 items-center text-sm font-semibold text-[var(--ui-primary)]">← {back}</Link>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { createTrialTenantAction, type PlatformTrialState } from "@/server/actions/trial-platform";
|
||||
|
||||
const ERR = "rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]";
|
||||
|
||||
function ErrorText({ state }: { state: PlatformTrialState }) {
|
||||
const t = useTranslations("trial.platform.errors");
|
||||
if (state.status !== "error") return null;
|
||||
return <p role="alert" className={ERR}>{t.has(state.code) ? t(state.code) : t("failed")}</p>;
|
||||
}
|
||||
|
||||
/** L15 Testphase: platform wizard "Testmandant anlegen" (one page, four sections). */
|
||||
export function PlatformTrialCreateForm({ defaultEnd, min, max }: { defaultEnd: string; min: string; max: string }) {
|
||||
const t = useTranslations("trial.platform.wizard");
|
||||
const [state, action, pending] = useActionState<PlatformTrialState, FormData>(createTrialTenantAction, { status: "idle" });
|
||||
const section = "shadow-card rounded-xl border bg-card p-5";
|
||||
return (
|
||||
<form action={action} className="grid gap-4">
|
||||
<fieldset className={section}>
|
||||
<legend className="font-heading text-sm font-semibold">{t("sectionCompany")}</legend>
|
||||
<div className="mt-3 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="companyName">{t("companyName")} *</Label>
|
||||
<Input id="companyName" name="companyName" required minLength={2} maxLength={120} className="mt-1 h-11" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sector">{t("sector")}</Label>
|
||||
<Input id="sector" name="sector" maxLength={80} className="mt-1 h-11" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset className={section}>
|
||||
<legend className="font-heading text-sm font-semibold">{t("sectionAdmin")}</legend>
|
||||
<div className="mt-3 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="adminName">{t("adminName")} *</Label>
|
||||
<Input id="adminName" name="adminName" required minLength={2} maxLength={120} className="mt-1 h-11" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="adminEmail">{t("adminEmail")} *</Label>
|
||||
<Input id="adminEmail" name="adminEmail" type="email" required maxLength={200} className="mt-1 h-11" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-[12px] text-muted-foreground">{t("adminHint")}</p>
|
||||
</fieldset>
|
||||
<fieldset className={section}>
|
||||
<legend className="font-heading text-sm font-semibold">{t("sectionPeriod")}</legend>
|
||||
<div className="mt-3 max-w-xs">
|
||||
<Label htmlFor="endDate">{t("endDate")} *</Label>
|
||||
<Input id="endDate" name="endDate" type="date" required min={min} max={max} defaultValue={defaultEnd} className="mt-1 h-11" />
|
||||
<p className="mt-1 text-[12px] text-muted-foreground">{t("endDateHint")}</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset className={section}>
|
||||
<legend className="font-heading text-sm font-semibold">{t("sectionSetup")}</legend>
|
||||
<label className="mt-3 flex min-h-11 items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="sampleData" defaultChecked className="size-5" /> {t("sampleData")}
|
||||
</label>
|
||||
</fieldset>
|
||||
<ErrorText state={state} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" className="h-11" disabled={pending}>{pending ? t("submitting") : t("submit")}</Button>
|
||||
<Button variant="outline" className="h-11" nativeButton={false} render={<Link href="/admin" />}>{t("cancel")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** L15 Testphase: confirmation form of one lifecycle operation (bound server action). */
|
||||
export function TrialLifecycleForm({
|
||||
action,
|
||||
withDate,
|
||||
defaultEnd,
|
||||
min,
|
||||
max,
|
||||
closeHref,
|
||||
destructive,
|
||||
}: {
|
||||
action: (prev: PlatformTrialState, fd: FormData) => Promise<PlatformTrialState>;
|
||||
withDate: boolean;
|
||||
defaultEnd?: string;
|
||||
min?: string;
|
||||
max?: string;
|
||||
closeHref: string;
|
||||
destructive?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("trial.platform.ops");
|
||||
const tw = useTranslations("trial.platform.wizard");
|
||||
const [state, formAction, pending] = useActionState<PlatformTrialState, FormData>(action, { status: "idle" });
|
||||
return (
|
||||
<form action={formAction} className="space-y-3">
|
||||
{withDate && (
|
||||
<div className="max-w-xs">
|
||||
<Label htmlFor="op-endDate">{tw("endDate")} *</Label>
|
||||
<Input id="op-endDate" name="endDate" type="date" required min={min} max={max} defaultValue={defaultEnd} className="mt-1 h-11" />
|
||||
</div>
|
||||
)}
|
||||
<label className="flex min-h-11 items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="confirm" className="size-5" required /> {t("confirm")}
|
||||
</label>
|
||||
<ErrorText state={state} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" className="h-11" variant={destructive ? "destructive" : "default"} disabled={pending}>{pending ? t("submitting") : t("submit")}</Button>
|
||||
<Button variant="outline" className="h-11" nativeButton={false} render={<Link href={closeHref} />}>{t("close")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check, MailCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { diffDayKeys, formatDateKey, type TrialBounds } from "@/lib/trial/dates";
|
||||
import {
|
||||
TRIAL_COMPANY_SIZES,
|
||||
TRIAL_SECTOR_KEYS,
|
||||
TRIAL_STEPS,
|
||||
validateTrialSignup,
|
||||
validateTrialStep,
|
||||
type FieldErrors,
|
||||
type TrialSignupValues,
|
||||
type TrialStep,
|
||||
} from "@/lib/trial/signup";
|
||||
import { checkTrialStepAction, submitTrialSignupAction } from "@/server/actions/trial-signup";
|
||||
|
||||
/**
|
||||
* L15 Testphase: public 5-step wizard. All values live in one state object, so "Zurück" never loses
|
||||
* input. Each step is validated in the browser (same rules as the server) and then by the server
|
||||
* (`checkTrialStepAction`); the final submit validates everything again.
|
||||
*/
|
||||
|
||||
type Props = {
|
||||
bounds: TrialBounds;
|
||||
modules: { key: string; label: string }[];
|
||||
passwordPolicy: string;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
const STEP_FIELDS: Record<TrialStep, (keyof TrialSignupValues)[]> = {
|
||||
company: ["companyName", "sector", "companySize"],
|
||||
account: ["adminName", "email", "password"],
|
||||
period: ["trialEndDate"],
|
||||
setup: ["sampleData", "modules"],
|
||||
summary: ["acceptTerms", "acceptPrivacy"],
|
||||
};
|
||||
|
||||
const fieldCls = "mt-1 h-11 text-base sm:text-sm";
|
||||
const selectCls = "mt-1 h-11 w-full rounded-md border border-input bg-background px-3 text-base sm:text-sm";
|
||||
const errorCls = "mt-1 text-[13px] text-[var(--risk)]";
|
||||
|
||||
export function TrialSignupWizard({ bounds, modules, passwordPolicy, locale }: Props) {
|
||||
const t = useTranslations("trial");
|
||||
const [step, setStep] = useState(0);
|
||||
const [sectorKey, setSectorKey] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [values, setValues] = useState<TrialSignupValues>({
|
||||
companyName: "",
|
||||
sector: "",
|
||||
companySize: "",
|
||||
adminName: "",
|
||||
email: "",
|
||||
password: "",
|
||||
trialEndDate: bounds.defaultEnd,
|
||||
sampleData: true,
|
||||
modules: modules.map((m) => m.key),
|
||||
acceptTerms: false,
|
||||
acceptPrivacy: false,
|
||||
website: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [sent, setSent] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const current = TRIAL_STEPS[step];
|
||||
const set = <K extends keyof TrialSignupValues>(key: K, value: TrialSignupValues[K]) => {
|
||||
setValues((v) => ({ ...v, [key]: value }));
|
||||
setErrors((e) => ({ ...e, [key]: undefined, _form: undefined }));
|
||||
};
|
||||
const days = useMemo(() => (/^\d{4}-\d{2}-\d{2}$/.test(values.trialEndDate) ? diffDayKeys(bounds.today, values.trialEndDate) : null), [values.trialEndDate, bounds.today]);
|
||||
const err = (key: keyof FieldErrors) => (errors[key] ? t(`errors.${errors[key]}`) : null);
|
||||
|
||||
function firstStepWithError(e: FieldErrors): number {
|
||||
const idx = TRIAL_STEPS.findIndex((s) => STEP_FIELDS[s].some((f) => e[f]));
|
||||
return idx === -1 ? step : idx;
|
||||
}
|
||||
|
||||
function next() {
|
||||
const local = validateTrialStep(current, values, bounds);
|
||||
if (Object.keys(local).length) return setErrors(local);
|
||||
startTransition(async () => {
|
||||
const server = await checkTrialStepAction(current, values);
|
||||
if (Object.keys(server).length) return setErrors(server);
|
||||
setErrors({});
|
||||
setStep((s) => Math.min(s + 1, TRIAL_STEPS.length - 1));
|
||||
});
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const local = validateTrialSignup(values, bounds);
|
||||
if (Object.keys(local).length) {
|
||||
setErrors(local);
|
||||
return setStep(firstStepWithError(local));
|
||||
}
|
||||
startTransition(async () => {
|
||||
const res = await submitTrialSignupAction(values);
|
||||
if (res.status === "sent") return setSent(true);
|
||||
setErrors(res.errors);
|
||||
setStep(firstStepWithError(res.errors));
|
||||
});
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div role="status" className="space-y-3 text-center">
|
||||
<MailCheck className="mx-auto size-10 text-[var(--ok)]" aria-hidden />
|
||||
<h2 className="font-heading text-xl font-semibold">{t("sent.title")}</h2>
|
||||
<p className="text-sm">{t("sent.body")}</p>
|
||||
<p className="text-[13px] text-muted-foreground">{t("sent.hint")}</p>
|
||||
<Button variant="outline" className="h-11" onClick={() => { setSent(false); setStep(0); }}>
|
||||
{t("sent.again")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (current === "summary") submit();
|
||||
else next();
|
||||
}}
|
||||
>
|
||||
{/* Progress */}
|
||||
<nav aria-label={t("progressLabel")}>
|
||||
<p className="text-[13px] font-medium text-muted-foreground">{t("progress", { current: step + 1, total: TRIAL_STEPS.length })}</p>
|
||||
<ol className="mt-2 grid grid-cols-5 gap-1.5">
|
||||
{TRIAL_STEPS.map((s, i) => (
|
||||
<li key={s} aria-current={i === step ? "step" : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={i > step || pending}
|
||||
onClick={() => setStep(i)}
|
||||
className="flex w-full flex-col items-start gap-1 text-left disabled:cursor-default"
|
||||
>
|
||||
<span className={cn("h-1.5 w-full rounded-full", i <= step ? "bg-[var(--ui-primary)]" : "bg-muted")} />
|
||||
<span className={cn("hidden text-[11.5px] sm:inline", i === step ? "font-semibold text-foreground" : "text-muted-foreground")}>
|
||||
{i < step && <Check className="mr-0.5 inline size-3" aria-hidden />}
|
||||
{t(`steps.${s}`)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h2 className="mt-5 font-heading text-lg font-semibold">{t(`steps.${current}`)}</h2>
|
||||
|
||||
{/* Honeypot: invisible for people, not announced to screen readers */}
|
||||
<div aria-hidden className="absolute -left-[10000px] top-auto size-px overflow-hidden">
|
||||
<label htmlFor="website">{t("fields.website")}</label>
|
||||
<input id="website" name="website" tabIndex={-1} autoComplete="off" value={values.website} onChange={(e) => set("website", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{current === "company" && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="companyName">{t("fields.companyName")} *</Label>
|
||||
<Input id="companyName" className={fieldCls} autoComplete="organization" value={values.companyName} onChange={(e) => set("companyName", e.target.value)} aria-invalid={!!errors.companyName} aria-describedby={errors.companyName ? "companyName-error" : undefined} />
|
||||
{err("companyName") && <p id="companyName-error" className={errorCls}>{err("companyName")}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sector">{t("fields.sector")}</Label>
|
||||
<select
|
||||
id="sector"
|
||||
className={selectCls}
|
||||
value={sectorKey}
|
||||
onChange={(e) => {
|
||||
const key = e.target.value;
|
||||
setSectorKey(key);
|
||||
set("sector", key && key !== "other" ? t(`sectors.${key}`) : "");
|
||||
}}
|
||||
>
|
||||
<option value="">{t("fields.sectorChoose")}</option>
|
||||
{TRIAL_SECTOR_KEYS.map((k) => (
|
||||
<option key={k} value={k}>{t(`sectors.${k}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
{sectorKey === "other" && (
|
||||
<>
|
||||
<Label htmlFor="sectorOther" className="mt-3 block">{t("fields.sectorOther")}</Label>
|
||||
<Input id="sectorOther" className={fieldCls} value={values.sector} onChange={(e) => set("sector", e.target.value)} />
|
||||
</>
|
||||
)}
|
||||
{err("sector") && <p className={errorCls}>{err("sector")}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="companySize">{t("fields.companySize")}</Label>
|
||||
<select id="companySize" className={selectCls} value={values.companySize} onChange={(e) => set("companySize", e.target.value)}>
|
||||
<option value="">{t("fields.companySizeNone")}</option>
|
||||
{TRIAL_COMPANY_SIZES.map((s) => (
|
||||
<option key={s} value={s}>{t(`sizes.${s.replace(/[^0-9]/g, "_")}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
{err("companySize") && <p className={errorCls}>{err("companySize")}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{current === "account" && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="adminName">{t("fields.adminName")} *</Label>
|
||||
<Input id="adminName" className={fieldCls} autoComplete="name" value={values.adminName} onChange={(e) => set("adminName", e.target.value)} aria-invalid={!!errors.adminName} />
|
||||
{err("adminName") && <p className={errorCls}>{err("adminName")}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">{t("fields.email")} *</Label>
|
||||
<Input id="email" type="email" inputMode="email" className={fieldCls} autoComplete="email" value={values.email} onChange={(e) => set("email", e.target.value)} aria-invalid={!!errors.email} aria-describedby="email-hint" />
|
||||
<p id="email-hint" className="mt-1 text-[12.5px] text-muted-foreground">{t("fields.emailHint")}</p>
|
||||
{err("email") && <p className={errorCls}>{err("email")}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="password">{t("fields.password")} *</Label>
|
||||
<Input id="password" type={showPassword ? "text" : "password"} className={fieldCls} autoComplete="new-password" value={values.password} onChange={(e) => set("password", e.target.value)} aria-invalid={!!errors.password} aria-describedby="password-hint" />
|
||||
<p id="password-hint" className="mt-1 text-[12.5px] text-muted-foreground">{t("fields.passwordHint", { policy: passwordPolicy })}</p>
|
||||
<label className="mt-2 flex min-h-11 items-center gap-2 text-sm">
|
||||
<input type="checkbox" className="size-5" checked={showPassword} onChange={(e) => setShowPassword(e.target.checked)} />
|
||||
{t("fields.showPassword")}
|
||||
</label>
|
||||
{err("password") && <p className={errorCls}>{err("password")}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{current === "period" && (
|
||||
<div>
|
||||
<Label htmlFor="trialEndDate">{t("fields.trialEndDate")} *</Label>
|
||||
<Input id="trialEndDate" type="date" className={fieldCls} min={bounds.min} max={bounds.max} value={values.trialEndDate} onChange={(e) => set("trialEndDate", e.target.value)} aria-invalid={!!errors.trialEndDate} aria-describedby="period-range" />
|
||||
<p id="period-range" className="mt-1 text-[12.5px] text-muted-foreground">
|
||||
{t("period.range", { min: formatDateKey(bounds.min, locale), max: formatDateKey(bounds.max, locale) })}
|
||||
</p>
|
||||
{err("trialEndDate") ? (
|
||||
<p className={errorCls}>{err("trialEndDate")}</p>
|
||||
) : (
|
||||
days !== null && days > 0 && (
|
||||
<p className="mt-3 rounded-lg bg-[var(--ui-primary-soft)] px-3 py-2 text-sm font-semibold" aria-live="polite">
|
||||
{t("period.until", { date: formatDateKey(values.trialEndDate, locale), days })}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
<p className="mt-3 text-[13px] text-muted-foreground">{t("period.afterEnd")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{current === "setup" && (
|
||||
<>
|
||||
<label className="flex min-h-11 items-start gap-3 rounded-lg border p-3">
|
||||
<input type="checkbox" className="mt-0.5 size-5" checked={values.sampleData} onChange={(e) => set("sampleData", e.target.checked)} />
|
||||
<span>
|
||||
<span className="block text-sm font-semibold">{t("fields.sampleData")}</span>
|
||||
<span className="block text-[12.5px] text-muted-foreground">{t("fields.sampleDataHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
<fieldset>
|
||||
<legend className="text-sm font-semibold">{t("fields.modules")}</legend>
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("fields.modulesHint")}</p>
|
||||
<div className="mt-2 grid gap-1 sm:grid-cols-2">
|
||||
{modules.map((m) => (
|
||||
<label key={m.key} className="flex min-h-11 items-center gap-2 rounded-md px-2 text-sm hover:bg-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-5"
|
||||
checked={values.modules.includes(m.key)}
|
||||
onChange={(e) => set("modules", e.target.checked ? [...values.modules, m.key] : values.modules.filter((k) => k !== m.key))}
|
||||
/>
|
||||
{m.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{err("modules") && <p className={errorCls}>{err("modules")}</p>}
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
|
||||
{current === "summary" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">{t("summary.intro")}</p>
|
||||
<dl className="divide-y rounded-lg border text-sm">
|
||||
{[
|
||||
{ step: 0, label: t("steps.company"), value: `${values.companyName} · ${values.sector || t("summary.noSector")}` },
|
||||
{ step: 1, label: t("steps.account"), value: `${values.adminName} · ${values.email}` },
|
||||
{ step: 2, label: t("steps.period"), value: days !== null ? t("period.until", { date: formatDateKey(values.trialEndDate, locale), days }) : values.trialEndDate },
|
||||
{ step: 3, label: t("steps.setup"), value: `${values.sampleData ? t("summary.sampleYes") : t("summary.sampleNo")} · ${t("summary.modulesCount", { count: values.modules.length, total: modules.length })}` },
|
||||
].map((row) => (
|
||||
<div key={row.step} className="flex items-start justify-between gap-3 px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[12px] text-muted-foreground">{row.label}</dt>
|
||||
<dd className="break-words">{row.value}</dd>
|
||||
</div>
|
||||
<button type="button" className="min-h-11 shrink-0 px-2 text-[13px] font-semibold text-[var(--ui-primary)]" onClick={() => setStep(row.step)}>
|
||||
{t("summary.edit")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<label className="flex min-h-11 items-start gap-3">
|
||||
<input type="checkbox" className="mt-0.5 size-5" checked={values.acceptTerms} onChange={(e) => set("acceptTerms", e.target.checked)} aria-invalid={!!errors.acceptTerms} />
|
||||
<span className="text-sm">
|
||||
{t.rich("fields.acceptTerms", { link: () => <Link href="/testen/nutzungsbedingungen" target="_blank" className="font-semibold text-[var(--ui-primary)] underline">{t("public.terms")}</Link> })}
|
||||
</span>
|
||||
</label>
|
||||
{err("acceptTerms") && <p className={errorCls}>{err("acceptTerms")}</p>}
|
||||
<label className="flex min-h-11 items-start gap-3">
|
||||
<input type="checkbox" className="mt-0.5 size-5" checked={values.acceptPrivacy} onChange={(e) => set("acceptPrivacy", e.target.checked)} aria-invalid={!!errors.acceptPrivacy} />
|
||||
<span className="text-sm">
|
||||
{t.rich("fields.acceptPrivacy", { link: () => <Link href="/testen/datenschutz" target="_blank" className="font-semibold text-[var(--ui-primary)] underline">{t("public.privacy")}</Link> })}
|
||||
</span>
|
||||
</label>
|
||||
{err("acceptPrivacy") && <p className={errorCls}>{err("acceptPrivacy")}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{err("_form") && (
|
||||
<p role="alert" className="mt-4 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">
|
||||
{err("_form")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-center justify-between gap-3">
|
||||
<Button type="button" variant="outline" className="h-11 min-w-24" disabled={step === 0 || pending} onClick={() => { setErrors({}); setStep((s) => Math.max(0, s - 1)); }}>
|
||||
{t("actions.back")}
|
||||
</Button>
|
||||
<Button type="submit" className="h-11 min-w-32" disabled={pending}>
|
||||
{current === "summary" ? (pending ? t("actions.submitting") : t("actions.submit")) : pending ? t("actions.checking") : t("actions.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Link from "next/link";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { addDaysToKey, formatDateKey, formatInstantDate, todayKey } from "@/lib/trial/dates";
|
||||
import { trialLifecycleAction } from "@/server/actions/trial-platform";
|
||||
import { PLATFORM_TRIAL_MAX_DAYS } from "@/server/services/trial/config";
|
||||
import { computeTrialState, type TenantTrialRow } from "@/server/services/trial/state";
|
||||
import { TrialLifecycleForm } from "./platform-forms";
|
||||
|
||||
const OPS = ["extend", "convert", "end", "schedule", "cancel"] as const;
|
||||
type Op = (typeof OPS)[number];
|
||||
|
||||
/**
|
||||
* L15 Testphase: trial card on the platform tenant detail page with lifecycle actions (full admins).
|
||||
* Each action opens a confirmation popup (?trial=<op>, existing Modal pattern).
|
||||
*/
|
||||
export async function TrialAdminCard({
|
||||
tenant,
|
||||
isFullAdmin,
|
||||
base,
|
||||
op,
|
||||
notice,
|
||||
}: {
|
||||
tenant: TenantTrialRow & { id: string; trialSource: string | null; status: string };
|
||||
isFullAdmin: boolean;
|
||||
base: string;
|
||||
op?: string;
|
||||
notice?: "created" | "invited" | "done" | null;
|
||||
}) {
|
||||
if (!tenant.trialStartedAt) return null;
|
||||
const [t, locale] = await Promise.all([getTranslations("trial.platform"), getLocale()]);
|
||||
const state = computeTrialState(tenant);
|
||||
const deleted = !!tenant.trialDeletedAt;
|
||||
const isTrial = tenant.plan === "TRIAL" && !deleted;
|
||||
const today = todayKey();
|
||||
const activeOp = isFullAdmin && isTrial && (OPS as readonly string[]).includes(op ?? "") ? (op as Op) : null;
|
||||
|
||||
const stateText = deleted ? t("card.stateDeleted") : tenant.plan === "FULL" ? t("card.stateConverted") : state.expired ? t("card.stateExpired") : t("card.stateActive");
|
||||
const actions: { op: Op; show: boolean }[] = [
|
||||
{ op: "extend", show: true },
|
||||
{ op: "convert", show: true },
|
||||
{ op: "end", show: !state.expired },
|
||||
{ op: "schedule", show: !tenant.deletionDueAt },
|
||||
{ op: "cancel", show: !!tenant.deletionDueAt },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="shadow-card rounded-xl border bg-card p-5" data-trial-card>
|
||||
<p className="mb-2 font-heading text-sm font-semibold">{t("card.title")}</p>
|
||||
{notice && <p role="status" className="mb-3 rounded-lg bg-[rgba(57,192,127,0.14)] px-3 py-2 text-[12.5px] text-[var(--ok)]">{t(`card.${notice}`)}</p>}
|
||||
<dl className="space-y-1.5 text-[13px]">
|
||||
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">{t("card.state")}</dt><dd><Pill tone={deleted ? "mut" : tenant.plan === "FULL" ? "ok" : "warn"}>{stateText}</Pill></dd></div>
|
||||
{state.endDateKey && <div className="flex justify-between gap-2"><dt className="text-muted-foreground">{t("card.until")}</dt><dd>{formatDateKey(state.endDateKey, locale)}</dd></div>}
|
||||
{isTrial && <div className="flex justify-between gap-2"><dt className="text-muted-foreground">{t("card.deletion")}</dt><dd>{tenant.deletionDueAt ? formatInstantDate(tenant.deletionDueAt, locale) : t("card.deletionNone")}</dd></div>}
|
||||
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">{t("card.source")}</dt><dd>{tenant.trialSource === "platform" ? t("card.sourcePlatform") : t("card.sourceSelf")}</dd></div>
|
||||
</dl>
|
||||
{isFullAdmin && isTrial && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{actions.filter((a) => a.show).map((a) => (
|
||||
<Link key={a.op} href={`${base}?trial=${a.op}`} scroll={false} className="block">
|
||||
<Button variant="outline" size="sm" className={a.op === "end" || a.op === "schedule" ? "min-h-11 w-full justify-center border-destructive/40 text-destructive" : "min-h-11 w-full justify-center"}>
|
||||
{t(`card.${a.op}`)}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeOp && (
|
||||
<Modal title={t(`ops.${activeOp}Title`)} closeHref={base} closeLabel={t("ops.close")}>
|
||||
<div className="space-y-3 p-5">
|
||||
<p className="text-sm">{t(`ops.${activeOp}Text`)}</p>
|
||||
<TrialLifecycleForm
|
||||
action={trialLifecycleAction.bind(null, tenant.id, activeOp)}
|
||||
withDate={activeOp === "extend"}
|
||||
defaultEnd={state.endDateKey && state.endDateKey >= today ? addDaysToKey(state.endDateKey, 14) : addDaysToKey(today, 14)}
|
||||
min={today}
|
||||
max={addDaysToKey(today, PLATFORM_TRIAL_MAX_DAYS)}
|
||||
closeHref={base}
|
||||
destructive={activeOp === "end" || activeOp === "schedule"}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { formatDateKey, formatInstantDate } from "@/lib/trial/dates";
|
||||
import { computeTrialState, type TenantTrialRow } from "@/server/services/trial/state";
|
||||
|
||||
/** L15 Testphase: plan badge(s) in the platform tenant list ("Test bis …", "abgelaufen – nur lesen", "Löschung am …"). */
|
||||
export async function TrialBadge({ tenant }: { tenant: TenantTrialRow }) {
|
||||
const [t, locale] = await Promise.all([getTranslations("trial.platform"), getLocale()]);
|
||||
if (tenant.trialDeletedAt) return <Pill tone="mut">{t("badgeDeleted")}</Pill>;
|
||||
const state = computeTrialState(tenant);
|
||||
if (!state.isTrial) return <Pill tone="ok">{t("badgeFull")}</Pill>;
|
||||
return (
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
{state.expired ? <Pill tone="warn">{t("badgeExpired")}</Pill> : <Pill tone="warn">{t("badgeUntil", { date: formatDateKey(state.endDateKey!, locale) })}</Pill>}
|
||||
{state.deletionDueAt && <Pill tone="mut">{t("badgeDeletion", { date: formatInstantDate(state.deletionDueAt, locale) })}</Pill>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Link from "next/link";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { Hourglass, Lock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDateKey, formatInstantDate } from "@/lib/trial/dates";
|
||||
import { trialContactEmail } from "@/server/services/trial/config";
|
||||
import { getTrialState } from "@/server/services/trial/state";
|
||||
|
||||
/**
|
||||
* L15 Testphase: banner in the backoffice and mobile shell — countdown from 7 days before the end,
|
||||
* read-only notice (+ deletion date, contact, export link) after expiry. Status is carried by text
|
||||
* and icon, colour only supports it.
|
||||
*/
|
||||
export async function TrialBanner({ tenantId, variant, canExport = false }: { tenantId: string; variant: "backoffice" | "mobile"; canExport?: boolean }) {
|
||||
const state = await getTrialState(tenantId);
|
||||
if (!state.isTrial || (!state.expired && !state.showCountdown)) return null;
|
||||
const [t, locale] = await Promise.all([getTranslations("trial.banner"), getLocale()]);
|
||||
const contact = trialContactEmail();
|
||||
const Icon = state.expired ? Lock : Hourglass;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-trial-banner={state.expired ? "expired" : "countdown"}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 text-[13px]",
|
||||
state.expired ? "bg-[rgba(198,66,66,0.08)] text-foreground" : "bg-[rgba(200,121,18,0.10)] text-foreground",
|
||||
variant === "mobile" && "text-[14px]",
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("size-4 shrink-0", state.expired ? "text-[var(--risk)]" : "text-[var(--warn)]")} aria-hidden />
|
||||
<span className="font-semibold">
|
||||
{state.expired ? t("expired") : t("endsIn", { days: Math.max(0, state.daysLeft ?? 0) })}
|
||||
</span>
|
||||
{!state.expired && state.endDateKey && <span>{t("endsOn", { date: formatDateKey(state.endDateKey, locale) })}</span>}
|
||||
{state.expired && state.deletionDueAt && <span>{t("deletion", { date: formatInstantDate(state.deletionDueAt, locale) })}</span>}
|
||||
<span className="text-muted-foreground">
|
||||
{contact ? (
|
||||
<>
|
||||
{t("contact", { email: "" })}
|
||||
<a href={`mailto:${contact}`} className="font-semibold text-[var(--ui-primary)] underline">{contact}</a>
|
||||
</>
|
||||
) : (
|
||||
t("contactGeneric")
|
||||
)}
|
||||
</span>
|
||||
{state.expired && canExport && (
|
||||
<Link href="/settings/export" className="ml-auto inline-flex min-h-11 items-center font-semibold text-[var(--ui-primary)] underline">
|
||||
{t("export")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
LayoutGrid,
|
||||
MapPinned,
|
||||
Receipt,
|
||||
Download,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { ModuleKey } from "@/lib/modules";
|
||||
@@ -77,6 +78,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
||||
{ href: "/settings/email", label: "email", icon: Mail, module: "notifications", permissions: ["tenant:manage"], section: "admin" },
|
||||
{ href: "/settings/audit", label: "audit", icon: History, permissions: ["audit:read"], section: "admin" },
|
||||
{ href: "/settings/lotse", label: "lotse", icon: Compass, permissions: ["tenant:manage"], section: "admin" },
|
||||
{ href: "/settings/export", label: "dataExport", icon: Download, permissions: ["tenant:manage"], section: "admin" }, // L15
|
||||
];
|
||||
|
||||
/** Filtert die Navigation nach aktiven Modulen und Rechten der Session. */
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* L15 Testphase: calendar-day helpers in Europe/Berlin (client-safe, no dependencies).
|
||||
* Date keys are `YYYY-MM-DD`. A trial "until 30.09." ends at the START of 01.10. in Berlin
|
||||
* (exclusive end instant, stored in `Tenant.trialEndsAt`).
|
||||
*/
|
||||
import { dayWindow, localDateKey } from "@/lib/reports/dates";
|
||||
|
||||
export const TRIAL_TZ = "Europe/Berlin";
|
||||
const DATE_KEY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export function isDateKey(value: unknown): value is string {
|
||||
if (typeof value !== "string" || !DATE_KEY.test(value)) return false;
|
||||
const [y, m, d] = value.split("-").map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
|
||||
}
|
||||
|
||||
/** Today's date key in Berlin. */
|
||||
export function todayKey(now: Date = new Date()): string {
|
||||
return localDateKey(now, TRIAL_TZ);
|
||||
}
|
||||
|
||||
export function addDaysToKey(key: string, days: number): string {
|
||||
const [y, m, d] = key.split("-").map(Number);
|
||||
return new Date(Date.UTC(y, m - 1, d + days)).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Whole calendar days from `from` to `to` (negative when `to` lies before `from`). */
|
||||
export function diffDayKeys(from: string, to: string): number {
|
||||
const [y1, m1, d1] = from.split("-").map(Number);
|
||||
const [y2, m2, d2] = to.split("-").map(Number);
|
||||
return Math.round((Date.UTC(y2, m2 - 1, d2) - Date.UTC(y1, m1 - 1, d1)) / 86_400_000);
|
||||
}
|
||||
|
||||
/** Exclusive end instant of the chosen last trial day (start of the next day in Berlin). */
|
||||
export function trialEndInstant(endDateKey: string): Date {
|
||||
return dayWindow(endDateKey, TRIAL_TZ).end;
|
||||
}
|
||||
|
||||
/** Last trial day (date key) of a stored exclusive end instant. */
|
||||
export function trialEndDateKey(endsAt: Date): string {
|
||||
return localDateKey(new Date(endsAt.getTime() - 1), TRIAL_TZ);
|
||||
}
|
||||
|
||||
/** `TT.MM.JJJJ` (de) or `DD/MM/YYYY` (en) of a date key. */
|
||||
export function formatDateKey(key: string, locale: string = "de"): string {
|
||||
const [y, m, d] = key.split("-");
|
||||
return locale === "en" ? `${d}/${m}/${y}` : `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
/** Formatted Berlin calendar date of an instant. */
|
||||
export function formatInstantDate(instant: Date, locale: string = "de"): string {
|
||||
return formatDateKey(localDateKey(instant, TRIAL_TZ), locale);
|
||||
}
|
||||
|
||||
export type TrialBounds = { today: string; min: string; max: string; defaultEnd: string; maxDays: number };
|
||||
|
||||
/** Allowed end dates of the self-service wizard: tomorrow … today + maxDays (default today + 14). */
|
||||
export function trialBounds(now: Date, maxDays: number, defaultDays = 14): TrialBounds {
|
||||
const today = todayKey(now);
|
||||
return {
|
||||
today,
|
||||
min: addDaysToKey(today, 1),
|
||||
max: addDaysToKey(today, maxDays),
|
||||
defaultEnd: addDaysToKey(today, Math.min(defaultDays, maxDays)),
|
||||
maxDays,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* L15 Testphase: wizard data model + validation (client-safe — used by the wizard for instant
|
||||
* feedback AND by the server actions/services as the authoritative check).
|
||||
* Errors are stable codes; the UI resolves them via messages `trial.errors.<code>`.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { DEFAULT_PASSWORD_POLICY, validatePassword } from "@/lib/password-policy";
|
||||
import { MODULE_KEYS } from "@/lib/modules";
|
||||
import { isDateKey, type TrialBounds } from "@/lib/trial/dates";
|
||||
|
||||
/** Sector presets (labels in messages `trial.sectors.<key>`); "other" = free text. */
|
||||
export const TRIAL_SECTOR_KEYS = ["shk", "electrical", "roofing", "carpentry", "painting", "facility", "construction", "metal", "other"] as const;
|
||||
export const TRIAL_COMPANY_SIZES = ["1-5", "6-20", "21-50", "51-200", "200+"] as const;
|
||||
|
||||
export const TRIAL_STEPS = ["company", "account", "period", "setup", "summary"] as const;
|
||||
export type TrialStep = (typeof TRIAL_STEPS)[number];
|
||||
|
||||
export type TrialSignupValues = {
|
||||
companyName: string;
|
||||
/** Stored sector text (preset label or free text). */
|
||||
sector: string;
|
||||
companySize: string;
|
||||
adminName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
trialEndDate: string;
|
||||
sampleData: boolean;
|
||||
modules: string[];
|
||||
acceptTerms: boolean;
|
||||
acceptPrivacy: boolean;
|
||||
/** Honeypot — must stay empty (hidden from people, filled by naive bots). */
|
||||
website: string;
|
||||
};
|
||||
|
||||
/** `_form` = error not tied to one field (malformed request, rate limit). */
|
||||
export type FieldErrors = Partial<Record<keyof TrialSignupValues | "_form", string>>;
|
||||
|
||||
/** Shape/coercion of untrusted input (server side). Content rules follow in `validateTrialStep`. */
|
||||
export const trialSignupInputSchema = z.object({
|
||||
companyName: z.string().max(500).default(""),
|
||||
sector: z.string().max(500).default(""),
|
||||
companySize: z.string().max(20).default(""),
|
||||
adminName: z.string().max(500).default(""),
|
||||
email: z.string().max(500).default(""),
|
||||
password: z.string().max(500).default(""),
|
||||
trialEndDate: z.string().max(20).default(""),
|
||||
sampleData: z.coerce.boolean().default(true),
|
||||
modules: z.array(z.string().max(40)).max(50).default([...MODULE_KEYS]),
|
||||
acceptTerms: z.coerce.boolean().default(false),
|
||||
acceptPrivacy: z.coerce.boolean().default(false),
|
||||
website: z.string().max(500).default(""),
|
||||
});
|
||||
|
||||
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||
|
||||
/** Normalised values (trimmed, lower-case e-mail, known modules only); null for malformed input. */
|
||||
export function normalizeTrialValues(input: unknown): TrialSignupValues | null {
|
||||
const parsed = trialSignupInputSchema.safeParse(input ?? {});
|
||||
if (!parsed.success) return null;
|
||||
const v = parsed.data;
|
||||
return {
|
||||
...v,
|
||||
companyName: v.companyName.trim(),
|
||||
sector: v.sector.trim(),
|
||||
companySize: v.companySize.trim(),
|
||||
adminName: v.adminName.trim(),
|
||||
email: v.email.trim().toLowerCase(),
|
||||
trialEndDate: v.trialEndDate.trim(),
|
||||
modules: [...new Set(v.modules.filter((m) => (MODULE_KEYS as readonly string[]).includes(m)))],
|
||||
};
|
||||
}
|
||||
|
||||
export function validateTrialStep(step: TrialStep, v: TrialSignupValues, bounds: TrialBounds): FieldErrors {
|
||||
const e: FieldErrors = {};
|
||||
switch (step) {
|
||||
case "company":
|
||||
if (v.companyName.length < 2) e.companyName = "company_required";
|
||||
else if (v.companyName.length > 120) e.companyName = "too_long";
|
||||
if (v.sector.length > 80) e.sector = "too_long";
|
||||
if (v.companySize && !(TRIAL_COMPANY_SIZES as readonly string[]).includes(v.companySize)) e.companySize = "invalid_choice";
|
||||
break;
|
||||
case "account":
|
||||
if (v.adminName.length < 2) e.adminName = "name_required";
|
||||
else if (v.adminName.length > 120) e.adminName = "too_long";
|
||||
if (!EMAIL.test(v.email) || v.email.length > 200) e.email = "email_invalid";
|
||||
if (validatePassword(v.password, DEFAULT_PASSWORD_POLICY).length > 0 || v.password.length > 200) e.password = "password_policy";
|
||||
break;
|
||||
case "period":
|
||||
if (!isDateKey(v.trialEndDate)) e.trialEndDate = "date_invalid";
|
||||
else if (v.trialEndDate < bounds.min) e.trialEndDate = "date_too_early";
|
||||
else if (v.trialEndDate > bounds.max) e.trialEndDate = "date_too_late";
|
||||
break;
|
||||
case "setup":
|
||||
if (v.modules.length === 0) e.modules = "modules_required";
|
||||
break;
|
||||
case "summary":
|
||||
if (!v.acceptTerms) e.acceptTerms = "terms_required";
|
||||
if (!v.acceptPrivacy) e.acceptPrivacy = "privacy_required";
|
||||
break;
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
/** All steps at once (final submit). */
|
||||
export function validateTrialSignup(v: TrialSignupValues, bounds: TrialBounds): FieldErrors {
|
||||
return TRIAL_STEPS.reduce<FieldErrors>((acc, step) => ({ ...acc, ...validateTrialStep(step, v, bounds) }), {});
|
||||
}
|
||||
+3
-1
@@ -9,7 +9,9 @@ import { NextResponse, type NextRequest } from "next/server";
|
||||
// SEC2: die Wiederherstellungs-Abläufe müssen ohne Session erreichbar sein —
|
||||
// der Nutzer ist gerade ausgesperrt. Ihre Absicherung sind Rate-Limit,
|
||||
// Enumeration-Neutralität und single-use-Tokens, nicht dieses Gate.
|
||||
const PUBLIC_PATHS = ["/login", "/api/auth", "/forgot-password", "/reset", "/invite", "/verify-email", "/platform/login", "/api/platform-auth"];
|
||||
// L15 Testphase: `/testen` (Wizard, Bestätigung, Nutzungsbedingungen, Datenschutz) ist öffentlich —
|
||||
// abgesichert über Rate-Limit je IP/E-Mail, Honeypot, Double-Opt-in und Enumeration-Neutralität.
|
||||
const PUBLIC_PATHS = ["/login", "/api/auth", "/forgot-password", "/reset", "/invite", "/verify-email", "/platform/login", "/api/platform-auth", "/testen"];
|
||||
|
||||
// Plattform-Bereich (getrennte Session/Login): diese Routen werden über das
|
||||
// Plattform-Cookie gegatet und leiten anonyme Besucher auf /platform/login —
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ForbiddenError, type Permission } from "@/server/rbac";
|
||||
import { assertModuleEnabled } from "@/server/modules";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { isTokenStillValid } from "@/server/sessions";
|
||||
import { assertTenantWritable } from "@/server/services/trial/state";
|
||||
|
||||
/**
|
||||
* Einheitlicher Einstieg für mutierende Server-Actions eines gegateten Moduls (§3.4).
|
||||
@@ -83,6 +84,9 @@ export function moduleGuard(moduleKey: string) {
|
||||
}
|
||||
|
||||
await assertModuleEnabled(session, moduleKey);
|
||||
// L15 Testphase: abgelaufene Testmandanten sind nur lesbar — zentrale Schreibsperre
|
||||
// (wirft ServiceError "blocked"/"trial_expired").
|
||||
await assertTenantWritable(session.user.tenantId);
|
||||
// `permissions` = DB-authoritative effective set; domain services derive their
|
||||
// scope decisions from it (src/server/services/context.ts#ctxFromGuard).
|
||||
return { session, db, permissions: effective as ReadonlySet<string> };
|
||||
|
||||
@@ -6,6 +6,7 @@ import { requireApiContext } from "@/server/api/context";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { updateLotseSettings } from "@/server/services/lotse/settings";
|
||||
import { assertTenantWritable } from "@/server/services/trial/state";
|
||||
|
||||
/**
|
||||
* /settings/lotse: switch the Lotse module on/off and set the address form (lane L9).
|
||||
@@ -19,6 +20,7 @@ export async function saveLotseSettings(fd: FormData): Promise<void> {
|
||||
try {
|
||||
requirePermission(session, "tenant:manage"); // fast JWT check; requireApiContext re-checks against the DB
|
||||
const ctx = await requireApiContext(null, "tenant:manage");
|
||||
await assertTenantWritable(ctx.tenantId); // L15: expired trial → settings read-only
|
||||
// L10b: empty = platform default (null); invalid numbers are rejected by the service schema
|
||||
const rawLimit = String(fd.get("monthlyTokenLimit") ?? "").trim();
|
||||
await updateLotseSettings(ctx, {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { assertTenantWritable } from "@/server/services/trial/state";
|
||||
|
||||
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
|
||||
|
||||
@@ -14,6 +15,7 @@ export async function updateTenantSettings(formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "tenant:manage");
|
||||
const tenantId = session.user.tenantId;
|
||||
await assertTenantWritable(tenantId); // L15: expired trial → read-only
|
||||
const db = dbForTenant(tenantId);
|
||||
|
||||
const orgName = z.string().trim().min(1).parse(formData.get("orgName"));
|
||||
|
||||
@@ -11,6 +11,7 @@ import { resolvePasswordPolicy } from "@/lib/password-policy";
|
||||
import { issueToken } from "@/server/auth-token";
|
||||
import { sendUserInvitationMail } from "@/server/auth-selfservice";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { assertTenantWritable, isTrialExpiredError, TRIAL_READ_ONLY_MESSAGE } from "@/server/services/trial/state";
|
||||
|
||||
/**
|
||||
* Benutzer- & Rollenverwaltung durch den Mandanten-Admin (Paket B). EXEMPT vom
|
||||
@@ -38,6 +39,7 @@ export type EditUserState =
|
||||
async function ctx(permission: "user:manage" | "role:manage") {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, permission);
|
||||
await assertTenantWritable(session.user.tenantId); // L15: expired trial → user administration read-only
|
||||
return { session, tenantId: session.user.tenantId, db: dbForTenant(session.user.tenantId) };
|
||||
}
|
||||
|
||||
@@ -48,6 +50,7 @@ async function ctx(permission: "user:manage" | "role:manage") {
|
||||
* ("Aktion konnte nicht abgeschlossen werden" + Formularverlust).
|
||||
*/
|
||||
function actionError(err: unknown): string {
|
||||
if (isTrialExpiredError(err)) return TRIAL_READ_ONLY_MESSAGE;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return msg || "Die Aktion konnte nicht ausgeführt werden.";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePlatformFullAdmin } from "@/server/platform-auth";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import {
|
||||
cancelTrialDeletion,
|
||||
changeTrialEndDate,
|
||||
convertTenantToFull,
|
||||
createPlatformTrialTenant,
|
||||
endTrialNow,
|
||||
scheduleTrialDeletion,
|
||||
} from "@/server/services/trial/admin";
|
||||
|
||||
/**
|
||||
* L15 Testphase — platform admin: wizard "Testmandant anlegen" and lifecycle actions.
|
||||
* EXEMPT from module gating; authorisation via the separate platform session (full admins only).
|
||||
* The services re-check the actor against the PlatformAdmin store and write the audit.
|
||||
*/
|
||||
|
||||
export type PlatformTrialState = { status: "idle" } | { status: "error"; code: string };
|
||||
|
||||
function errorCode(err: unknown): string {
|
||||
if (err instanceof ServiceError) return err.message;
|
||||
if (err && typeof err === "object" && "issues" in err) return "invalid_input";
|
||||
return "failed";
|
||||
}
|
||||
|
||||
export async function createTrialTenantAction(_prev: PlatformTrialState, fd: FormData): Promise<PlatformTrialState> {
|
||||
const { admin } = await requirePlatformFullAdmin();
|
||||
let target: string;
|
||||
try {
|
||||
const result = await createPlatformTrialTenant(
|
||||
{ platformAdminId: admin.id },
|
||||
{
|
||||
companyName: String(fd.get("companyName") ?? ""),
|
||||
sector: String(fd.get("sector") ?? ""),
|
||||
adminName: String(fd.get("adminName") ?? ""),
|
||||
adminEmail: String(fd.get("adminEmail") ?? ""),
|
||||
endDate: String(fd.get("endDate") ?? ""),
|
||||
sampleData: fd.get("sampleData") === "on",
|
||||
},
|
||||
);
|
||||
target = `/admin/${result.tenantId}?trial=created${result.invited ? "&invited=1" : ""}`;
|
||||
} catch (err) {
|
||||
return { status: "error", code: errorCode(err) };
|
||||
}
|
||||
revalidatePath("/admin");
|
||||
redirect(target);
|
||||
}
|
||||
|
||||
const OPS = ["extend", "convert", "end", "schedule", "cancel"] as const;
|
||||
|
||||
/** Lifecycle actions; every destructive/relevant change requires the confirmation checkbox. */
|
||||
export async function trialLifecycleAction(tenantId: string, op: string, _prev: PlatformTrialState, fd: FormData): Promise<PlatformTrialState> {
|
||||
const { admin } = await requirePlatformFullAdmin();
|
||||
const actor = { platformAdminId: admin.id };
|
||||
if (!(OPS as readonly string[]).includes(op)) return { status: "error", code: "invalid_input" };
|
||||
if (fd.get("confirm") !== "on") return { status: "error", code: "confirm_required" };
|
||||
try {
|
||||
if (op === "extend") await changeTrialEndDate(actor, tenantId, String(fd.get("endDate") ?? ""));
|
||||
else if (op === "convert") await convertTenantToFull(actor, tenantId);
|
||||
else if (op === "end") await endTrialNow(actor, tenantId);
|
||||
else if (op === "schedule") await scheduleTrialDeletion(actor, tenantId);
|
||||
else await cancelTrialDeletion(actor, tenantId);
|
||||
} catch (err) {
|
||||
return { status: "error", code: errorCode(err) };
|
||||
}
|
||||
revalidatePath("/admin");
|
||||
revalidatePath(`/admin/${tenantId}`);
|
||||
redirect(`/admin/${tenantId}?trialDone=${op}`);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthError } from "next-auth";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { signIn } from "@/server/auth";
|
||||
import { clientIp } from "@/server/auth-selfservice";
|
||||
import { signLoginTicket } from "@/server/login-ticket";
|
||||
import { enforceTrialRateLimit } from "@/server/services/trial/abuse";
|
||||
import { checkTrialStep, confirmTrialSignup, submitTrialSignup } from "@/server/services/trial/signup";
|
||||
import type { FieldErrors } from "@/lib/trial/signup";
|
||||
|
||||
/**
|
||||
* L15 Testphase — public self-service signup (no session). Registered as PUBLIC in
|
||||
* scripts/check-module-guards.ts: every action checks the rate limit (per IP, per e-mail).
|
||||
* Business logic in src/server/services/trial/signup.ts.
|
||||
*/
|
||||
|
||||
export type TrialSubmitState = { status: "sent" } | { status: "invalid"; errors: FieldErrors };
|
||||
export type TrialConfirmState = { status: "idle" } | { status: "invalid" } | { status: "expired" } | { status: "rate_limited" };
|
||||
|
||||
async function localeOrDefault(): Promise<string> {
|
||||
try {
|
||||
return await getLocale();
|
||||
} catch {
|
||||
return "de";
|
||||
}
|
||||
}
|
||||
|
||||
/** Server-side validation of one wizard step (Weiter). Writes nothing. */
|
||||
export async function checkTrialStepAction(step: string, values: unknown): Promise<FieldErrors> {
|
||||
const limit = enforceTrialRateLimit("trialStepCheck", { ip: await clientIp() });
|
||||
if (!limit.allowed) return { _form: "rate_limited" };
|
||||
return checkTrialStep(step, values);
|
||||
}
|
||||
|
||||
/** Final submit: validates everything again, creates the pending signup and sends the confirmation mail. */
|
||||
export async function submitTrialSignupAction(values: unknown): Promise<TrialSubmitState> {
|
||||
const ip = await clientIp();
|
||||
const email = values && typeof values === "object" && typeof (values as { email?: unknown }).email === "string" ? (values as { email: string }).email : null;
|
||||
const limit = enforceTrialRateLimit("trialSignup", { ip, email });
|
||||
if (!limit.allowed) return { status: "invalid", errors: { _form: "rate_limited" } };
|
||||
return submitTrialSignup(values, { ip, locale: await localeOrDefault() });
|
||||
}
|
||||
|
||||
/** Confirmation (POST from /testen/bestaetigen — mail scanners opening the GET link consume nothing). */
|
||||
export async function confirmTrialSignupAction(_prev: TrialConfirmState, fd: FormData): Promise<TrialConfirmState> {
|
||||
const limit = enforceTrialRateLimit("trialConfirm", { ip: await clientIp() });
|
||||
if (!limit.allowed) return { status: "rate_limited" };
|
||||
const token = String(fd.get("token") ?? "");
|
||||
const result = await confirmTrialSignup(token);
|
||||
if (result.status !== "ok") return { status: result.status };
|
||||
|
||||
// Directly signed in via the existing login finalisation (login-ticket provider). If that is not
|
||||
// possible (e.g. MFA policy), the regular login page takes over.
|
||||
try {
|
||||
await signIn("login-ticket", { ticket: signLoginTicket(result.identityId, result.tenantSlug), redirectTo: "/dashboard?welcome=1" });
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) redirect("/login?trial=ready");
|
||||
throw err; // NEXT_REDIRECT of a successful sign-in
|
||||
}
|
||||
return { status: "idle" };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { requestTenantExport } from "@/server/services/trial/export";
|
||||
import { setOnboardingHidden, setOnboardingItem } from "@/server/services/trial/onboarding";
|
||||
|
||||
/**
|
||||
* L15 Testphase — tenant side: data export (allowed in the read-only state, therefore NOT behind
|
||||
* moduleGuard) and the "Erste Schritte" checklist (writes → trial write lock in the service).
|
||||
* Auth: requireSession + requirePermission (JWT, fast) + requireApiContext (DB-authoritative).
|
||||
* Registered as EXEMPT in scripts/check-module-guards.ts.
|
||||
*/
|
||||
|
||||
async function adminCtx() {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "tenant:manage");
|
||||
return requireApiContext(null, "tenant:manage");
|
||||
}
|
||||
|
||||
export async function requestExportAction(): Promise<void> {
|
||||
let target = "/settings/export?requested=1";
|
||||
try {
|
||||
await requestTenantExport(await adminCtx());
|
||||
} catch (err) {
|
||||
console.error("[actions/trial-tenant] export:", (err as Error).message);
|
||||
target = `/settings/export?error=${err instanceof ServiceError ? encodeURIComponent(err.message) : "failed"}`;
|
||||
}
|
||||
revalidatePath("/settings/export");
|
||||
redirect(target);
|
||||
}
|
||||
|
||||
export async function toggleOnboardingItemAction(fd: FormData): Promise<void> {
|
||||
const ctx = await adminCtx();
|
||||
await setOnboardingItem(ctx, String(fd.get("key") ?? ""), fd.get("done") === "1");
|
||||
revalidatePath("/dashboard");
|
||||
}
|
||||
|
||||
export async function hideOnboardingAction(): Promise<void> {
|
||||
const ctx = await adminCtx();
|
||||
await setOnboardingHidden(ctx, true);
|
||||
revalidatePath("/dashboard");
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { assertModuleEnabled, requireModule } from "@/server/modules";
|
||||
import type { Permission } from "@/server/rbac";
|
||||
import type { ModuleKey } from "@/lib/modules";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { ApiError } from "@/server/api/respond";
|
||||
import { ApiError, isMutatingApiRequest } from "@/server/api/respond";
|
||||
import { assertTenantWritable } from "@/server/services/trial/state";
|
||||
import { consumeRateLimit } from "@/server/rate-limit";
|
||||
|
||||
// assertSameOrigin lives in respond.ts (withApi applies it to every mutation); re-exported for
|
||||
@@ -63,10 +64,20 @@ export async function requireApiContext(moduleKey: ModuleKey | null, ...permissi
|
||||
}
|
||||
if (moduleKey) await assertModuleEnabled(session, moduleKey); // throws ModuleDisabledError → 403
|
||||
if (moduleKey) enforceApiRateLimit(session.user.id, moduleKey);
|
||||
await assertApiWriteAllowed(tenantId);
|
||||
|
||||
return { db, tenantId, userId: session.user.id, permissions: effective };
|
||||
}
|
||||
|
||||
/**
|
||||
* L15 Testphase: non-GET /api/v1 requests (withApi) of an expired trial tenant → `blocked
|
||||
* trial_expired` (422). GET requests (reads, PDFs, downloads, export) are never blocked. Route
|
||||
* handlers outside `withApi` that write (backoffice upload) call `assertTenantWritable` themselves.
|
||||
*/
|
||||
export async function assertApiWriteAllowed(tenantId: string): Promise<void> {
|
||||
if (isMutatingApiRequest()) await assertTenantWritable(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user request budget for /api/v1 (in-memory, per app instance — see rate-limit.ts).
|
||||
* Field endpoints (sync outbox, uploads, offline pre-download, document cache) get the generous
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { ZodError } from "zod";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { ForbiddenError } from "@/server/rbac";
|
||||
@@ -96,6 +97,22 @@ export function parsePagination(url: URL | string, defaults = { pageSize: 25 }):
|
||||
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
/**
|
||||
* L15 Testphase: HTTP method of the /api/v1 request currently handled by `withApi` (async context).
|
||||
* `requireApiContext` uses it to apply the trial write lock to every non-safe request centrally;
|
||||
* outside `withApi` (server actions, /files downloads) the method is unknown → `null`.
|
||||
*/
|
||||
const apiRequest = new AsyncLocalStorage<{ method: string }>();
|
||||
|
||||
export function currentApiMethod(): string | null {
|
||||
return apiRequest.getStore()?.method ?? null;
|
||||
}
|
||||
|
||||
export function isMutatingApiRequest(): boolean {
|
||||
const method = currentApiMethod();
|
||||
return method !== null && !SAFE_METHODS.has(method);
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF defense for cookie-authenticated, state-changing route handlers: reject requests whose
|
||||
* Origin (or Sec-Fetch-Site) shows a foreign site. Server actions have this built in.
|
||||
@@ -125,8 +142,9 @@ export function assertSameOrigin(req: Request): void {
|
||||
export function withApi<A extends [Request, ...unknown[]]>(handler: (...args: A) => Promise<Response>) {
|
||||
return async (...args: A): Promise<Response> => {
|
||||
try {
|
||||
if (!SAFE_METHODS.has(args[0].method.toUpperCase())) assertSameOrigin(args[0]);
|
||||
return await handler(...args);
|
||||
const method = args[0].method.toUpperCase();
|
||||
if (!SAFE_METHODS.has(method)) assertSameOrigin(args[0]);
|
||||
return await apiRequest.run({ method }, () => handler(...args));
|
||||
} catch (err) {
|
||||
return toErrorResponse(err);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,8 @@ export const TENANT_MODELS: readonly string[] = [
|
||||
// L14 Abrechnungsübersicht
|
||||
"WorkOrderMilestone",
|
||||
"BillingRecord",
|
||||
// L15 Testphase: Datenexport des Mandanten
|
||||
"TenantExport",
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -126,6 +126,8 @@ const TENANT_MODELS = new Set<string>([
|
||||
// L14 Abrechnungsübersicht
|
||||
"WorkOrderMilestone",
|
||||
"BillingRecord",
|
||||
// L15 Testphase: Datenexport des Mandanten
|
||||
"TenantExport",
|
||||
// WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier.
|
||||
// Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts
|
||||
// (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('<table>')` in der Migration
|
||||
|
||||
@@ -55,6 +55,8 @@ export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [
|
||||
{ model: "WorkOrderMilestone", field: "rejectedById" },
|
||||
{ model: "BillingRecord", field: "billedById" },
|
||||
{ model: "BillingRecord", field: "voidedById" },
|
||||
// L15 Testphase: Datenexport
|
||||
{ model: "TenantExport", field: "requestedById" },
|
||||
// Free-text person data of END CUSTOMERS (Customer/Contact/Site/Signature.signerName) is
|
||||
// tenant business data under data processing — not part of the employee subject export.
|
||||
];
|
||||
|
||||
@@ -16,6 +16,8 @@ export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor
|
||||
"geocode-site": () => import("./geocode-site").then((m) => m.process), // L13: geocoding of sites (OSM Nominatim)
|
||||
"planning-watch": () => import("./planning-watch").then((m) => m.process), // L13: delay/overrun/freed-capacity alerts (every 5 min)
|
||||
"billing-pdf": () => import(/* turbopackIgnore: true */ "./billing-pdf").then((m) => m.process), // L14: worker-only billing sheet PDF (react-dom/server + Chromium)
|
||||
"trial-lifecycle": () => import("./trial-lifecycle").then((m) => m.process), // L15: daily trial reminders/expiry/deletion
|
||||
"tenant-export": () => import("./tenant-export").then((m) => m.process), // L15: tenant data export ZIP
|
||||
};
|
||||
|
||||
/** Inline fallback when no Redis is available (dev/demo). */
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { JobPayload } from "../queues";
|
||||
import { buildTenantExport } from "@/server/services/trial/export";
|
||||
|
||||
/** L15 Testphase: builds the data export ZIP of one tenant (entityId = TenantExport.id). */
|
||||
export async function process(payload: JobPayload): Promise<void> {
|
||||
await buildTenantExport(payload.tenantId, payload.entityId);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { JobPayload } from "../queues";
|
||||
import { runTrialLifecycle } from "@/server/services/trial/lifecycle";
|
||||
|
||||
/** L15 Testphase: daily lifecycle run over all trial tenants (payload tenantId "*"). */
|
||||
export async function process(_payload: JobPayload): Promise<void> {
|
||||
void _payload;
|
||||
const s = await runTrialLifecycle();
|
||||
console.info(
|
||||
`[trial-lifecycle] checked ${s.checked}, reminders ${s.reminders}, expired ${s.expiredNotices}, deletion notices ${s.deletionNotices}, deleted ${s.deleted.length}, purged signups ${s.purgedSignups}`,
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ export const JOB_QUEUES = {
|
||||
geocodeSite: "geocode-site", // L13 Planung: site address → coordinates (OSM Nominatim, 1 req/s)
|
||||
planningWatch: "planning-watch", // L13 Planung: delay/overrun/freed-capacity alerts every 5 min
|
||||
billingPdf: "billing-pdf", // L14 Abrechnungsblatt
|
||||
trialLifecycle: "trial-lifecycle", // L15 Testphase: daily reminders/expiry/deletion
|
||||
tenantExport: "tenant-export", // L15 Testphase: tenant data export (ZIP)
|
||||
} as const;
|
||||
|
||||
export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES];
|
||||
@@ -111,6 +113,17 @@ export async function scheduleRecurringJobs(connection: Redis): Promise<void> {
|
||||
} finally {
|
||||
await watch.close();
|
||||
}
|
||||
// L15 Testphase: trial lifecycle once a day (all trial tenants; idempotent per tenant/notice)
|
||||
const trial = new Queue<JobPayload>(JOB_QUEUES.trialLifecycle, { connection });
|
||||
try {
|
||||
await trial.upsertJobScheduler(
|
||||
"trial-lifecycle-daily",
|
||||
{ every: 24 * 60 * 60 * 1000 },
|
||||
{ name: JOB_QUEUES.trialLifecycle, data: { tenantId: "*", entityId: "lifecycle" }, opts: { removeOnComplete: { count: 30 }, removeOnFail: { count: 30 } } },
|
||||
);
|
||||
} finally {
|
||||
await trial.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeJobQueues(): Promise<void> {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+22
-10
@@ -8,8 +8,15 @@ export interface ProvisionOpts {
|
||||
slug: string;
|
||||
short?: string;
|
||||
sector?: string;
|
||||
admin: { email: string; name: string; password: string };
|
||||
/**
|
||||
* Erster Administrator. `password` (Klartext, wird gehasht) ODER `passwordHash` (bereits
|
||||
* Argon2id + Pepper, z. B. aus der bestätigten Testphasen-Anmeldung — L15).
|
||||
* `mustChangePassword` gilt nur für eine NEU angelegte Identity (Einladungsweg).
|
||||
*/
|
||||
admin: { email: string; name: string; password?: string; passwordHash?: string; mustChangePassword?: boolean };
|
||||
actorId?: string | null;
|
||||
/** L15: nur diese Module aktivieren (neue Mandanten); ohne Angabe alle. */
|
||||
modules?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,12 +59,17 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
|
||||
|
||||
// 4. Erster Mandantenadministrator: globale Identity (Anmeldung) + Mitgliedschaft.
|
||||
// Idempotent: eine bestehende Identity/ihr Passwort wird NICHT überschrieben.
|
||||
const passwordHash = await hashPassword(opts.admin.password);
|
||||
const identity = await prisma.identity.upsert({
|
||||
where: { email: opts.admin.email },
|
||||
update: {},
|
||||
create: { email: opts.admin.email, passwordHash },
|
||||
});
|
||||
const existingIdentity = await prisma.identity.findUnique({ where: { email: opts.admin.email }, select: { id: true } });
|
||||
const identity =
|
||||
existingIdentity ??
|
||||
(await prisma.identity.create({
|
||||
data: {
|
||||
email: opts.admin.email,
|
||||
passwordHash: opts.admin.passwordHash ?? (await hashPassword(opts.admin.password ?? "")),
|
||||
mustChangePassword: opts.admin.mustChangePassword ?? false,
|
||||
},
|
||||
select: { id: true },
|
||||
}));
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { tenantId_email: { tenantId: tenant.id, email: opts.admin.email } },
|
||||
update: { name: opts.admin.name, identityId: identity.id },
|
||||
@@ -84,12 +96,12 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
|
||||
},
|
||||
});
|
||||
|
||||
// 6. Alle Module aktivieren
|
||||
// 6. Module aktivieren (L15: optional nur die gewählten; bestehende Zeilen bleiben unverändert)
|
||||
for (const m of MODULES) {
|
||||
await prisma.tenantModule.upsert({
|
||||
where: { tenantId_moduleKey: { tenantId: tenant.id, moduleKey: m.key } },
|
||||
update: {},
|
||||
create: { tenantId: tenant.id, moduleKey: m.key, enabled: true },
|
||||
create: { tenantId: tenant.id, moduleKey: m.key, enabled: opts.modules ? opts.modules.includes(m.key) : true },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,5 +109,5 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
|
||||
data: { tenantId: tenant.id, scope: "platform", actorId: opts.actorId ?? null, action: "provision", entity: "tenant", entityId: tenant.id, after: { name: opts.name } },
|
||||
});
|
||||
|
||||
return tenant;
|
||||
return Object.assign(tenant, { adminUserId: admin.id, identityId: identity.id, identityCreated: !existingIdentity });
|
||||
}
|
||||
|
||||
@@ -62,6 +62,12 @@ export const RATE_LIMITS = {
|
||||
api: { limit: perMinute("API_RATE_LIMIT_PER_MINUTE", 300), windowMs: 60_000 },
|
||||
/** L10b: Einsatz-/Sync-Endpunkte (/sync, /uploads, /field/**) je Nutzer — großzügig (Outbox, Vorab-Download). */
|
||||
apiField: { limit: perMinute("API_FIELD_RATE_LIMIT_PER_MINUTE", 1200), windowMs: 60_000 },
|
||||
/** L15: öffentliche Testphasen-Anmeldung — je IP und je E-Mail-Adresse. */
|
||||
trialSignup: { limit: 5, windowMs: 60 * 60_000 },
|
||||
/** L15: Einlösen des Bestätigungslinks je IP. */
|
||||
trialConfirm: { limit: 20, windowMs: 15 * 60_000 },
|
||||
/** L15: serverseitige Schrittprüfung des Wizards (schreibt nichts) je IP. */
|
||||
trialStepCheck: { limit: 120, windowMs: 15 * 60_000 },
|
||||
} as const satisfies Record<string, RateLimitRule>;
|
||||
|
||||
export type RateLimitScope = keyof typeof RATE_LIMITS;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { checkRateLimit, type RateLimitResult } from "@/server/rate-limit";
|
||||
|
||||
/**
|
||||
* L15 Testphase: abuse protection of the public endpoints (no external CAPTCHA).
|
||||
* Counts per IP AND per e-mail address (both counters are always increased, see rate-limit.ts).
|
||||
* The key material is hashed inside rate-limit.ts — no clear-text IPs/addresses in memory.
|
||||
*/
|
||||
export function enforceTrialRateLimit(
|
||||
scope: "trialSignup" | "trialConfirm" | "trialStepCheck",
|
||||
parts: { ip?: string | null; email?: string | null },
|
||||
): RateLimitResult {
|
||||
return checkRateLimit(scope, { ip: parts.ip ?? null, account: parts.email ? parts.email.trim().toLowerCase() : null });
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* L15 Testphase: operating parameters (env, read at call time so tests can override them).
|
||||
* TRIAL_MAX_DAYS longest self-service trial in days (1–365, default 30)
|
||||
* TRIAL_CONTACT_EMAIL contact shown in banners and trial mails (optional)
|
||||
*/
|
||||
|
||||
export const TRIAL_DEFAULT_DAYS = 14;
|
||||
/** Automatic deletion of an unconverted trial tenant after its end. */
|
||||
export const TRIAL_DELETION_GRACE_DAYS = 30;
|
||||
/** Deletion notice before the deletion date. */
|
||||
export const TRIAL_DELETION_NOTICE_DAYS = 7;
|
||||
/** Reminder mails before the end (calendar days in Berlin). */
|
||||
export const TRIAL_REMINDER_DAYS = [7, 3, 1] as const;
|
||||
/** Banner countdown from this many days before the end. */
|
||||
export const TRIAL_BANNER_DAYS = 7;
|
||||
/** Double opt-in link validity. */
|
||||
export const TRIAL_SIGNUP_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
/** Unconfirmed/finished signups are purged this long after their link expired (data minimisation). */
|
||||
export const TRIAL_SIGNUP_RETENTION_DAYS = 7;
|
||||
/** Platform admins may set end dates up to one year ahead. */
|
||||
export const PLATFORM_TRIAL_MAX_DAYS = 365;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
export const daysMs = (days: number) => days * DAY_MS;
|
||||
|
||||
export function trialMaxDays(): number {
|
||||
const v = Number(process.env.TRIAL_MAX_DAYS);
|
||||
return Number.isInteger(v) && v >= 1 && v <= 365 ? v : 30;
|
||||
}
|
||||
|
||||
export function trialContactEmail(): string | null {
|
||||
const v = process.env.TRIAL_CONTACT_EMAIL?.trim();
|
||||
return v && /^[^\s@]+@[^\s@]+$/.test(v) ? v : null;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { prisma, dbForTenant } from "@/server/db";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { buildZip, type ZipEntry } from "@/server/backup/zip";
|
||||
import { enqueueJob, JOB_QUEUES } from "@/server/jobs/queues";
|
||||
import { storage } from "@/server/storage/adapter";
|
||||
import { readStoredBytes } from "@/server/services/documents/read";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { todayKey } from "@/lib/trial/dates";
|
||||
import { daysMs } from "./config";
|
||||
|
||||
/**
|
||||
* L15 Testphase: data export for tenant administrators — ZIP with CSV (semicolon, UTF-8 BOM, Excel)
|
||||
* and JSON of master data, work orders, times, material, reports, plus the stored files (documents,
|
||||
* report PDFs, photos). Runs as worker job `tenant-export`; the result is stored under
|
||||
* `<tenantId>/uploads/…` in the object storage and downloaded via /settings/export/<id> (session +
|
||||
* tenant:manage, never a public link). Allowed in the read-only state (not a business mutation).
|
||||
*
|
||||
* The existing backup/DSGVO export (src/server/backup/dsgvo-zip.ts) is an operator tool (platform
|
||||
* portal, all tables incl. internal ones, JSON only); its ZIP writer is reused here.
|
||||
*/
|
||||
|
||||
export const EXPORT_DOWNLOAD_DAYS = 7;
|
||||
/** Upper bound for embedded files; beyond that the README lists what was left out. */
|
||||
const MAX_FILE_BYTES = 500 * 1024 * 1024;
|
||||
|
||||
type Dispatch = (tenantId: string, exportId: string, actorId: string) => Promise<void>;
|
||||
|
||||
const defaultDispatch: Dispatch = async (tenantId, exportId, actorId) => {
|
||||
if (!(await enqueueJob(JOB_QUEUES.tenantExport, { tenantId, entityId: exportId, actorId }))) {
|
||||
await buildTenantExport(tenantId, exportId); // no Redis (dev/demo): inline
|
||||
}
|
||||
};
|
||||
|
||||
export async function requestTenantExport(ctx: ServiceCtx, deps: { dispatch?: Dispatch; now?: Date } = {}) {
|
||||
assertCan(ctx, "tenant:manage");
|
||||
const now = deps.now ?? new Date();
|
||||
const open = await ctx.db.tenantExport.findFirst({ where: { status: { in: ["queued", "running"] }, createdAt: { gt: new Date(now.getTime() - 30 * 60_000) } }, select: { id: true } });
|
||||
if (open) throw new ServiceError("conflict", "export_running");
|
||||
const row = await ctx.db.tenantExport.create({ data: { tenantId: ctx.tenantId, requestedById: ctx.userId } });
|
||||
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "export", entity: "tenant_export", entityId: row.id, after: { status: "queued" } });
|
||||
await (deps.dispatch ?? defaultDispatch)(ctx.tenantId, row.id, ctx.userId);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function listTenantExports(ctx: ServiceCtx, opts: { now?: Date } = {}) {
|
||||
assertCan(ctx, "tenant:manage");
|
||||
const now = (opts.now ?? new Date()).getTime();
|
||||
const rows = await ctx.db.tenantExport.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
select: { id: true, status: true, fileName: true, bytes: true, error: true, expiresAt: true, createdAt: true },
|
||||
});
|
||||
return rows.map((r) => ({ ...r, expired: r.status === "done" && !!r.expiresAt && r.expiresAt.getTime() <= now }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- serialisation
|
||||
|
||||
function scalar(v: unknown): string {
|
||||
if (v === null || v === undefined) return "";
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
if (typeof v === "bigint") return v.toString();
|
||||
if (typeof v === "object") {
|
||||
// Prisma.Decimal
|
||||
if (typeof (v as { toFixed?: unknown }).toFixed === "function") return String(v);
|
||||
return JSON.stringify(v, (_k, x) => (typeof x === "bigint" ? x.toString() : x));
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function csvCell(v: unknown): string {
|
||||
let s = scalar(v);
|
||||
// CSV/formula injection: never let a cell start with a formula character
|
||||
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
|
||||
return /[";\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
}
|
||||
|
||||
export function toCsv(rows: Record<string, unknown>[]): string {
|
||||
const headers: string[] = [];
|
||||
for (const row of rows) for (const key of Object.keys(row)) if (!headers.includes(key)) headers.push(key);
|
||||
const lines = [headers.map(csvCell).join(";"), ...rows.map((row) => headers.map((h) => csvCell(row[h])).join(";"))];
|
||||
return `${lines.join("\r\n")}\r\n`;
|
||||
}
|
||||
|
||||
function toJson(rows: unknown): string {
|
||||
return JSON.stringify(rows, (_k, v) => (typeof v === "bigint" ? v.toString() : v && typeof v === "object" && typeof (v as { toFixed?: unknown }).toFixed === "function" && !(v instanceof Date) ? String(v) : v), 2);
|
||||
}
|
||||
|
||||
const safeName = (s: string) => s.replace(/[^\w.\-]+/g, "_").slice(0, 100) || "datei";
|
||||
|
||||
/** Builds the ZIP for one export row (worker). Idempotent: only a `queued` row is processed. */
|
||||
export async function buildTenantExport(tenantId: string, exportId: string, opts: { now?: Date } = {}): Promise<void> {
|
||||
const now = opts.now ?? new Date();
|
||||
const db = dbForTenant(tenantId);
|
||||
const claim = await db.tenantExport.updateMany({ where: { id: exportId, status: "queued" }, data: { status: "running" } });
|
||||
if (claim.count !== 1) return;
|
||||
const row = await db.tenantExport.findFirst({ where: { id: exportId }, select: { requestedById: true } });
|
||||
|
||||
try {
|
||||
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { name: true, slug: true } });
|
||||
if (!tenant) throw new Error("tenant not found");
|
||||
|
||||
const datasets: [string, Promise<Record<string, unknown>[]>][] = [
|
||||
["kunden", db.customer.findMany({ orderBy: { createdAt: "asc" } })],
|
||||
["ansprechpartner", db.contact.findMany({ orderBy: { createdAt: "asc" } })],
|
||||
["objekte", db.site.findMany({ orderBy: { createdAt: "asc" } })],
|
||||
["teams", db.team.findMany({ orderBy: { createdAt: "asc" } })],
|
||||
["team-mitglieder", db.teamMember.findMany()],
|
||||
["nutzer", db.user.findMany({ select: { id: true, name: true, email: true, status: true, createdAt: true } })],
|
||||
["auftraege", db.workOrder.findMany({ orderBy: { createdAt: "asc" } })],
|
||||
["checklisten", db.checklistItem.findMany()],
|
||||
["material-vorgabe", db.materialPlan.findMany()],
|
||||
["material", db.materialUsage.findMany({ orderBy: { createdAt: "asc" } })],
|
||||
["einsaetze", db.workSession.findMany()],
|
||||
["zeiten", db.timeEntry.findMany({ orderBy: { startedAt: "asc" } })],
|
||||
["notizen", db.activityNote.findMany({ orderBy: { createdAt: "asc" } })],
|
||||
["berichte", db.report.findMany({ orderBy: { createdAt: "asc" } })],
|
||||
["unterschriften", db.signature.findMany()],
|
||||
["fotos", db.photo.findMany()],
|
||||
["dokumente", db.document.findMany({ where: { deletedAt: null }, orderBy: { createdAt: "asc" } })],
|
||||
["meilensteine", db.workOrderMilestone.findMany()],
|
||||
["abrechnung", db.billingRecord.findMany()],
|
||||
];
|
||||
|
||||
const entries: ZipEntry[] = [];
|
||||
const counts: Record<string, number> = {};
|
||||
let documents: { id: string; fileName: string; storageKey: string; fileSize: number }[] = [];
|
||||
for (const [name, query] of datasets) {
|
||||
const rows = await query;
|
||||
counts[name] = rows.length;
|
||||
entries.push({ name: `csv/${name}.csv`, data: toCsv(rows) });
|
||||
entries.push({ name: `json/${name}.json`, data: toJson(rows) });
|
||||
if (name === "dokumente") documents = rows as unknown as typeof documents;
|
||||
}
|
||||
|
||||
let fileBytes = 0;
|
||||
let files = 0;
|
||||
const skipped: string[] = [];
|
||||
for (const doc of documents) {
|
||||
if (fileBytes + doc.fileSize > MAX_FILE_BYTES) {
|
||||
skipped.push(`${doc.id} ${doc.fileName} (Größenlimit)`);
|
||||
continue;
|
||||
}
|
||||
const bytes = await readStoredBytes(doc.storageKey).catch(() => null);
|
||||
if (!bytes) {
|
||||
skipped.push(`${doc.id} ${doc.fileName} (nicht im Speicher)`);
|
||||
continue;
|
||||
}
|
||||
entries.push({ name: `dateien/${doc.id}-${safeName(doc.fileName)}`, data: bytes });
|
||||
fileBytes += bytes.length;
|
||||
files++;
|
||||
}
|
||||
|
||||
entries.unshift({
|
||||
name: "LIESMICH.txt",
|
||||
data:
|
||||
`Craftvia – Datenexport\r\nBetrieb: ${tenant.name} (${tenant.slug})\r\nErstellt: ${now.toISOString()}\r\n\r\n` +
|
||||
`csv/ Tabellen im CSV-Format (Semikolon, UTF-8) – öffnen z. B. mit Excel oder LibreOffice\r\n` +
|
||||
`json/ dieselben Daten als JSON (maschinenlesbar)\r\n` +
|
||||
`dateien/ gespeicherte Dateien (Dokumente, Berichts-PDFs, Fotos, Unterschriften); Dateiname beginnt mit der Dokument-ID aus dokumente.csv\r\n\r\n` +
|
||||
`Datensätze: ${Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(", ")}\r\n` +
|
||||
`Dateien: ${files}${skipped.length ? `\r\nNicht enthalten:\r\n${skipped.join("\r\n")}` : ""}\r\n`,
|
||||
});
|
||||
|
||||
const zip = buildZip(entries);
|
||||
const fileName = `craftvia-export-${tenant.slug}-${todayKey(now)}.zip`;
|
||||
const stored = await storage.put({ tenantId, filename: fileName, contentType: "application/zip", bytes: zip });
|
||||
if (stored.storageKey.startsWith("stub://")) throw new Error("object storage not configured");
|
||||
|
||||
const summary = { counts, files, skipped: skipped.length };
|
||||
await db.tenantExport.update({
|
||||
where: { id: exportId },
|
||||
data: { status: "done", storageKey: stored.storageKey, fileName, bytes: zip.length, summary, expiresAt: new Date(now.getTime() + daysMs(EXPORT_DOWNLOAD_DAYS)), error: null },
|
||||
});
|
||||
await writeAuditLog({ tenantId, actorId: row?.requestedById ?? undefined, action: "export", entity: "tenant_export", entityId: exportId, after: { status: "done", bytes: zip.length, ...summary } });
|
||||
} catch (err) {
|
||||
console.error(`[tenant-export] ${exportId} failed:`, (err as Error).message);
|
||||
await db.tenantExport.update({ where: { id: exportId }, data: { status: "failed", error: (err as Error).message.slice(0, 300) } });
|
||||
}
|
||||
}
|
||||
|
||||
/** Download of a finished export (tenant:manage; expired links → invalid). */
|
||||
export async function openTenantExport(ctx: ServiceCtx, exportId: string, opts: { now?: Date } = {}): Promise<{ bytes: Buffer; fileName: string }> {
|
||||
assertCan(ctx, "tenant:manage");
|
||||
const now = opts.now ?? new Date();
|
||||
const row = await ctx.db.tenantExport.findFirst({ where: { id: exportId, status: "done" }, select: { id: true, storageKey: true, fileName: true, expiresAt: true } });
|
||||
if (!row || !row.storageKey) throw new ServiceError("not_found", "export not found");
|
||||
if (row.expiresAt && row.expiresAt.getTime() <= now.getTime()) throw new ServiceError("invalid", "export_expired");
|
||||
// defence in depth: the key must belong to this tenant
|
||||
if (!row.storageKey.startsWith(`${ctx.tenantId}/`)) throw new ServiceError("not_found", "export not found");
|
||||
const bytes = await readStoredBytes(row.storageKey);
|
||||
if (!bytes) throw new ServiceError("not_found", "export file missing");
|
||||
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "export", entity: "tenant_export_download", entityId: row.id });
|
||||
return { bytes, fileName: row.fileName ?? "craftvia-export.zip" };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getTrialState } from "./state";
|
||||
|
||||
/**
|
||||
* L15 Testphase: background jobs that write ON BEHALF OF A USER must not run for an expired trial
|
||||
* tenant (e.g. an import extraction or transcription queued shortly before the end).
|
||||
* System derivations of data committed before the end (report/billing PDFs, thumbnails, geocoding)
|
||||
* and platform maintenance jobs keep running; the trial jobs themselves are never blocked.
|
||||
*/
|
||||
export const TRIAL_BLOCKED_QUEUES: readonly string[] = ["import-extraction", "transcription"];
|
||||
|
||||
export async function isJobBlockedByTrial(queue: string, payload: { tenantId?: string | null }): Promise<boolean> {
|
||||
if (!TRIAL_BLOCKED_QUEUES.includes(queue)) return false;
|
||||
if (!payload.tenantId || payload.tenantId === "*") return false;
|
||||
return (await getTrialState(payload.tenantId)).readOnly;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { prisma } from "@/server/db";
|
||||
import { writePlatformAudit } from "@/server/audit";
|
||||
import { offboardTenant } from "@/server/dsgvo/deletion";
|
||||
import { absoluteUrl } from "@/server/mail/config";
|
||||
import { enqueueMail } from "@/server/mail/service";
|
||||
import { normalizeLocale } from "@/server/mail/templates";
|
||||
import { formatDateKey, formatInstantDate } from "@/lib/trial/dates";
|
||||
import { daysMs, trialContactEmail, TRIAL_DELETION_NOTICE_DAYS, TRIAL_REMINDER_DAYS, TRIAL_SIGNUP_RETENTION_DAYS } from "./config";
|
||||
import { computeTrialState, TRIAL_SELECT } from "./state";
|
||||
import { tenantAdminRecipients } from "./mail";
|
||||
import { purgeTenantObjects } from "./storage-purge";
|
||||
|
||||
/**
|
||||
* L15 Testphase: daily lifecycle job (BullMQ job scheduler `trial-lifecycle-daily`, idempotent).
|
||||
* - reminders 7/3/1 days before the end (each at most once; a missed run sends only the closest)
|
||||
* - expiry mail on the end day (+ readOnlySince)
|
||||
* - deletion notice TRIAL_DELETION_NOTICE_DAYS before `deletionDueAt`
|
||||
* - deletion at `deletionDueAt` through the existing tenant offboarding (topology of all
|
||||
* TENANT_MODELS + storage prefix `<tenantId>/`), platform audit
|
||||
* - purge of old signup rows (data minimisation)
|
||||
* Every mail is "claimed" with a conditional update (column still NULL) before sending, so parallel
|
||||
* workers or re-runs never send twice. Converted or extended tenants are never deleted: the
|
||||
* deletion re-checks and claims the row immediately before offboarding.
|
||||
*/
|
||||
|
||||
export type LifecycleSummary = {
|
||||
checked: number;
|
||||
reminders: number;
|
||||
expiredNotices: number;
|
||||
deletionNotices: number;
|
||||
deleted: string[];
|
||||
purgedSignups: number;
|
||||
};
|
||||
|
||||
type Deps = { now?: Date; tenantIds?: string[]; sendMail?: typeof enqueueMail };
|
||||
|
||||
const REMINDER_COLUMN = { 7: "trialReminder7At", 3: "trialReminder3At", 1: "trialReminder1At" } as const;
|
||||
|
||||
async function mailAdmins(
|
||||
tenantId: string,
|
||||
send: typeof enqueueMail,
|
||||
build: (r: { name: string; email: string; locale: "de" | "en" }) => Parameters<typeof enqueueMail>[0],
|
||||
): Promise<number> {
|
||||
let n = 0;
|
||||
for (const r of await tenantAdminRecipients(tenantId)) {
|
||||
const res = await send(build({ name: r.name, email: r.email, locale: normalizeLocale(r.locale) }));
|
||||
if (res.status !== "duplicate") n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export async function runTrialLifecycle(deps: Deps = {}): Promise<LifecycleSummary> {
|
||||
const now = deps.now ?? new Date();
|
||||
const send = deps.sendMail ?? enqueueMail;
|
||||
const contact = trialContactEmail() ?? undefined;
|
||||
const summary: LifecycleSummary = { checked: 0, reminders: 0, expiredNotices: 0, deletionNotices: 0, deleted: [], purgedSignups: 0 };
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { plan: "TRIAL", trialDeletedAt: null, status: { not: "ARCHIVED" }, trialEndsAt: { not: null }, ...(deps.tenantIds ? { id: { in: deps.tenantIds } } : {}) },
|
||||
select: { id: true, name: true, ...TRIAL_SELECT, trialReminder7At: true, trialReminder3At: true, trialReminder1At: true, trialExpiredNoticeAt: true, trialDeletionNoticeAt: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
for (const t of tenants) {
|
||||
summary.checked++;
|
||||
try {
|
||||
const state = computeTrialState(t, now);
|
||||
const endDate = (locale: string) => formatDateKey(state.endDateKey!, locale);
|
||||
|
||||
if (!state.expired) {
|
||||
const threshold = [...TRIAL_REMINDER_DAYS].sort((a, b) => a - b).find((d) => state.daysLeft! <= d);
|
||||
if (threshold !== undefined && !t[REMINDER_COLUMN[threshold]]) {
|
||||
// claim this threshold and every larger one (a missed run must not send an older reminder later)
|
||||
const columns = TRIAL_REMINDER_DAYS.filter((d) => d >= threshold).map((d) => REMINDER_COLUMN[d]);
|
||||
const claim = await prisma.tenant.updateMany({
|
||||
where: { id: t.id, plan: "TRIAL", [REMINDER_COLUMN[threshold]]: null },
|
||||
data: Object.fromEntries(columns.map((c) => [c, now])),
|
||||
});
|
||||
if (claim.count === 1) {
|
||||
await mailAdmins(t.id, send, (r) => ({
|
||||
template: "trial_reminder",
|
||||
to: r.email,
|
||||
tenantId: t.id,
|
||||
locale: r.locale,
|
||||
vars: { name: r.name, tenantName: t.name, daysLeft: state.daysLeft!, endDate: endDate(r.locale), actionUrl: absoluteUrl("/dashboard"), contact },
|
||||
}));
|
||||
summary.reminders++;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!t.trialExpiredNoticeAt) {
|
||||
const claim = await prisma.tenant.updateMany({
|
||||
where: { id: t.id, plan: "TRIAL", trialExpiredNoticeAt: null },
|
||||
data: { trialExpiredNoticeAt: now, readOnlySince: t.readOnlySince ?? t.trialEndsAt },
|
||||
});
|
||||
if (claim.count === 1) {
|
||||
await mailAdmins(t.id, send, (r) => ({
|
||||
template: "trial_expired",
|
||||
to: r.email,
|
||||
tenantId: t.id,
|
||||
locale: r.locale,
|
||||
vars: { name: r.name, tenantName: t.name, endDate: endDate(r.locale), deletionDate: t.deletionDueAt ? formatInstantDate(t.deletionDueAt, r.locale) : undefined, exportUrl: absoluteUrl("/settings/export"), contact },
|
||||
}));
|
||||
summary.expiredNotices++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!t.deletionDueAt) continue;
|
||||
if (now.getTime() >= t.deletionDueAt.getTime()) {
|
||||
const res = await deleteTrialTenant(t.id, { now });
|
||||
if (res.deleted) summary.deleted.push(t.id);
|
||||
} else if (now.getTime() >= t.deletionDueAt.getTime() - daysMs(TRIAL_DELETION_NOTICE_DAYS) && !t.trialDeletionNoticeAt) {
|
||||
const claim = await prisma.tenant.updateMany({ where: { id: t.id, plan: "TRIAL", trialDeletionNoticeAt: null, deletionDueAt: t.deletionDueAt }, data: { trialDeletionNoticeAt: now } });
|
||||
if (claim.count === 1) {
|
||||
await mailAdmins(t.id, send, (r) => ({
|
||||
template: "trial_deletion_notice",
|
||||
to: r.email,
|
||||
tenantId: t.id,
|
||||
locale: r.locale,
|
||||
vars: { name: r.name, tenantName: t.name, deletionDate: formatInstantDate(t.deletionDueAt!, r.locale), exportUrl: absoluteUrl("/settings/export"), contact },
|
||||
}));
|
||||
summary.deletionNotices++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// one broken tenant must not stop the others; the next run retries
|
||||
console.error(`[trial-lifecycle] tenant ${t.id} failed:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!deps.tenantIds) {
|
||||
const { count } = await prisma.trialSignup.deleteMany({ where: { expiresAt: { lt: new Date(now.getTime() - daysMs(TRIAL_SIGNUP_RETENTION_DAYS)) } } });
|
||||
summary.purgedSignups = count;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an expired, unconverted trial tenant. Double check + claim right before the deletion:
|
||||
* the conditional update only matches while the tenant is still TRIAL, unconverted, expired and due —
|
||||
* a conversion/extension a moment earlier makes it a no-op. The tenant is suspended during deletion
|
||||
* (no logins) and archived by the offboarding afterwards.
|
||||
*/
|
||||
export async function deleteTrialTenant(tenantId: string, opts: { now?: Date } = {}): Promise<{ deleted: boolean }> {
|
||||
const now = opts.now ?? new Date();
|
||||
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true, slug: true } });
|
||||
if (!tenant) return { deleted: false };
|
||||
const claim = await prisma.tenant.updateMany({
|
||||
where: {
|
||||
id: tenantId,
|
||||
plan: "TRIAL",
|
||||
convertedAt: null,
|
||||
trialDeletedAt: null,
|
||||
status: { not: "ARCHIVED" },
|
||||
trialEndsAt: { lte: now },
|
||||
deletionDueAt: { lte: now },
|
||||
},
|
||||
data: { status: "SUSPENDED" },
|
||||
});
|
||||
if (claim.count !== 1) return { deleted: false };
|
||||
|
||||
const result = await offboardTenant(tenantId, { reason: "trial_expired", purgeFiles: true, portabilitySnapshot: false });
|
||||
let files = 0;
|
||||
try {
|
||||
files = await purgeTenantObjects(tenantId);
|
||||
} catch (err) {
|
||||
console.error(`[trial-lifecycle] storage purge for ${tenantId} failed:`, (err as Error).message);
|
||||
}
|
||||
await prisma.tenant.update({ where: { id: tenantId }, data: { trialDeletedAt: now, deletionDueAt: null } });
|
||||
await prisma.trialSignup.deleteMany({ where: { provisionedTenantId: tenantId } });
|
||||
await writePlatformAudit({
|
||||
action: "delete",
|
||||
entity: "trial_tenant",
|
||||
entityId: tenantId,
|
||||
after: { slug: tenant.slug, deletedRows: result.deletedRows, deletedIdentities: result.deletedIdentities, files, certificateId: result.certificateId },
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { dbForTenant } from "@/server/db";
|
||||
|
||||
/** L15 Testphase: active tenant administrators (recipients of reminder/expiry/deletion mails). */
|
||||
export async function tenantAdminRecipients(tenantId: string): Promise<{ userId: string; email: string; name: string; locale: string }[]> {
|
||||
const users = await dbForTenant(tenantId).user.findMany({
|
||||
where: { status: "ACTIVE", userRoles: { some: { role: { key: "tenant-admin" } } } },
|
||||
select: { id: true, email: true, name: true, identity: { select: { status: true, uiLocale: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return users.filter((u) => u.identity.status === "ACTIVE").map((u) => ({ userId: u.id, email: u.email, name: u.name, locale: u.identity.uiLocale }));
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { prisma } from "@/server/db";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { SAMPLE_ORDER_PREFIX, SAMPLE_TEAM_NAME } from "./sample-data";
|
||||
import { assertTenantWritable } from "./state";
|
||||
|
||||
/**
|
||||
* L15 Testphase: "Erste Schritte" checklist on the dashboard of tenants that started as a trial.
|
||||
* Items are detected automatically where possible (sample data does not count) and can be ticked
|
||||
* manually; the whole card can be hidden. State in `TenantSettings.onboarding`.
|
||||
*/
|
||||
|
||||
export const ONBOARDING_ITEMS = [
|
||||
{ key: "company", href: "/settings" },
|
||||
{ key: "team", href: "/teams" },
|
||||
{ key: "technician", href: "/settings/users" },
|
||||
{ key: "first_order", href: "/work-orders/new" },
|
||||
{ key: "mobile", href: "/m" },
|
||||
] as const;
|
||||
|
||||
export type OnboardingKey = (typeof ONBOARDING_ITEMS)[number]["key"];
|
||||
type OnboardingJson = { done: string[]; hidden: boolean };
|
||||
|
||||
function parse(value: unknown): OnboardingJson {
|
||||
const v = value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
return { done: Array.isArray(v.done) ? v.done.filter((x): x is string => typeof x === "string") : [], hidden: v.hidden === true };
|
||||
}
|
||||
|
||||
export function isOnboardingKey(key: string): key is OnboardingKey {
|
||||
return ONBOARDING_ITEMS.some((i) => i.key === key);
|
||||
}
|
||||
|
||||
export async function getOnboardingChecklist(ctx: ServiceCtx) {
|
||||
if (!can(ctx, "tenant:manage")) return null;
|
||||
const tenant = await prisma.tenant.findUnique({ where: { id: ctx.tenantId }, select: { trialStartedAt: true } });
|
||||
if (!tenant?.trialStartedAt) return null;
|
||||
const settings = await ctx.db.tenantSettings.findFirst({ select: { onboarding: true, address: true, phone: true, email: true } });
|
||||
const state = parse(settings?.onboarding);
|
||||
if (state.hidden) return null;
|
||||
|
||||
const [teams, fieldUsers, orders] = await Promise.all([
|
||||
ctx.db.team.count({ where: { deletedAt: null, name: { not: SAMPLE_TEAM_NAME } } }),
|
||||
ctx.db.user.count({ where: { userRoles: { some: { role: { key: { in: ["technician", "team-lead"] } } } } } }),
|
||||
ctx.db.workOrder.count({ where: { OR: [{ externalOrderNumber: null }, { NOT: { externalOrderNumber: { startsWith: SAMPLE_ORDER_PREFIX } } }] } }),
|
||||
]);
|
||||
const auto: Record<OnboardingKey, boolean> = {
|
||||
company: !!(settings?.address || settings?.phone || settings?.email),
|
||||
team: teams > 0,
|
||||
technician: fieldUsers > 0,
|
||||
first_order: orders > 0,
|
||||
mobile: false,
|
||||
};
|
||||
const items = ONBOARDING_ITEMS.map((i) => ({ key: i.key, href: i.href, auto: auto[i.key], done: auto[i.key] || state.done.includes(i.key) }));
|
||||
return { items, completed: items.filter((i) => i.done).length, total: items.length };
|
||||
}
|
||||
|
||||
async function write(ctx: ServiceCtx, next: OnboardingJson, before: OnboardingJson) {
|
||||
await ctx.db.tenantSettings.update({ where: { tenantId: ctx.tenantId }, data: { onboarding: next } });
|
||||
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "onboarding", entityId: ctx.tenantId, before, after: next });
|
||||
}
|
||||
|
||||
async function load(ctx: ServiceCtx): Promise<OnboardingJson> {
|
||||
const settings = await ctx.db.tenantSettings.findFirst({ select: { onboarding: true } });
|
||||
if (!settings) throw new ServiceError("not_found", "tenant settings not found");
|
||||
return parse(settings.onboarding);
|
||||
}
|
||||
|
||||
export async function setOnboardingItem(ctx: ServiceCtx, key: string, done: boolean) {
|
||||
assertCan(ctx, "tenant:manage");
|
||||
if (!isOnboardingKey(key)) throw new ServiceError("invalid", "unknown_item", { field: "key" });
|
||||
await assertTenantWritable(ctx.tenantId);
|
||||
const before = await load(ctx);
|
||||
const set = new Set(before.done);
|
||||
if (done) set.add(key);
|
||||
else set.delete(key);
|
||||
await write(ctx, { ...before, done: [...set] }, before);
|
||||
}
|
||||
|
||||
export async function setOnboardingHidden(ctx: ServiceCtx, hidden: boolean) {
|
||||
assertCan(ctx, "tenant:manage");
|
||||
await assertTenantWritable(ctx.tenantId);
|
||||
const before = await load(ctx);
|
||||
await write(ctx, { ...before, hidden }, before);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { prisma } from "@/server/db";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { generateCompliantPassword, hashPassword } from "@/server/password";
|
||||
import { provisionTenant } from "@/server/provision";
|
||||
import { MODULE_KEYS } from "@/lib/modules";
|
||||
import { trialEndInstant, todayKey } from "@/lib/trial/dates";
|
||||
import { daysMs, TRIAL_DELETION_GRACE_DAYS } from "./config";
|
||||
import { seedTrialSampleData } from "./sample-data";
|
||||
|
||||
/**
|
||||
* L15 Testphase: create a trial tenant (self-service signup after double opt-in, or platform wizard).
|
||||
*
|
||||
* The tenant row is CREATED here first (unique slug, trial fields) and only then completed by the
|
||||
* idempotent `provisionTenant` (roles, admin, settings, modules). `provisionTenant` upserts by slug —
|
||||
* reserving the slug with a plain `create` first guarantees that two concurrent signups with the same
|
||||
* company name never end up in the same tenant.
|
||||
*/
|
||||
|
||||
export function slugifyCompany(name: string): string {
|
||||
const base = name
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/ß/g, "ss")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40)
|
||||
.replace(/-+$/g, "");
|
||||
return base.length >= 3 ? base : "betrieb";
|
||||
}
|
||||
|
||||
export type ProvisionTrialInput = {
|
||||
companyName: string;
|
||||
sector?: string | null;
|
||||
companySize?: string | null;
|
||||
admin: { name: string; email: string; passwordHash?: string };
|
||||
/** Last trial day YYYY-MM-DD (Berlin). */
|
||||
endDateKey: string;
|
||||
sampleData: boolean;
|
||||
modules?: readonly string[];
|
||||
source: "self_signup" | "platform";
|
||||
actorId?: string | null;
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
export type ProvisionTrialResult = {
|
||||
tenantId: string;
|
||||
tenantSlug: string;
|
||||
adminUserId: string;
|
||||
identityId: string;
|
||||
identityCreated: boolean;
|
||||
sampleData: "created" | "skipped" | "failed";
|
||||
};
|
||||
|
||||
async function reserveTenant(input: ProvisionTrialInput, now: Date) {
|
||||
const base = slugifyCompany(input.companyName);
|
||||
const endsAt = trialEndInstant(input.endDateKey < todayKey(now) ? todayKey(now) : input.endDateKey);
|
||||
const candidates = [base, ...Array.from({ length: 19 }, (_, i) => `${base}-${i + 2}`), `${base}-${randomBytes(3).toString("hex")}`, `${base}-${randomBytes(4).toString("hex")}`];
|
||||
for (const slug of candidates) {
|
||||
try {
|
||||
return await prisma.tenant.create({
|
||||
data: {
|
||||
name: input.companyName,
|
||||
slug,
|
||||
sector: input.sector || null,
|
||||
plan: "TRIAL",
|
||||
trialSource: input.source,
|
||||
trialStartedAt: now,
|
||||
trialEndsAt: endsAt,
|
||||
deletionDueAt: new Date(endsAt.getTime() + daysMs(TRIAL_DELETION_GRACE_DAYS)),
|
||||
config: { trial: { companySize: input.companySize || null, source: input.source } },
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") continue; // slug taken → next candidate
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw new Error("no free tenant slug");
|
||||
}
|
||||
|
||||
export async function provisionTrialTenant(input: ProvisionTrialInput): Promise<ProvisionTrialResult> {
|
||||
const now = input.now ?? new Date();
|
||||
const email = input.admin.email.trim().toLowerCase();
|
||||
const tenant = await reserveTenant(input, now);
|
||||
const modules = input.modules?.length ? input.modules.filter((m) => (MODULE_KEYS as readonly string[]).includes(m)) : [...MODULE_KEYS];
|
||||
|
||||
// Without a password (platform wizard): unusable random password + forced change; the admin sets
|
||||
// their own password through the existing invitation link (actions/platform-users.ts pattern).
|
||||
const passwordHash = input.admin.passwordHash ?? (await hashPassword(generateCompliantPassword()));
|
||||
const provisioned = await provisionTenant(prisma, {
|
||||
name: input.companyName,
|
||||
slug: tenant.slug,
|
||||
sector: input.sector || undefined,
|
||||
admin: { email, name: input.admin.name, passwordHash, mustChangePassword: !input.admin.passwordHash },
|
||||
modules,
|
||||
actorId: input.actorId ?? null,
|
||||
});
|
||||
|
||||
let sampleData: ProvisionTrialResult["sampleData"] = "skipped";
|
||||
if (input.sampleData) {
|
||||
try {
|
||||
await seedTrialSampleData(tenant.id, provisioned.adminUserId);
|
||||
sampleData = "created";
|
||||
} catch (err) {
|
||||
// sample data is a convenience — the trial itself must not fail because of it
|
||||
console.error("[trial] sample data failed:", (err as Error).message);
|
||||
sampleData = "failed";
|
||||
}
|
||||
}
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: tenant.id,
|
||||
scope: "platform",
|
||||
actorId: input.actorId ?? undefined,
|
||||
action: "create",
|
||||
entity: "trial",
|
||||
entityId: tenant.id,
|
||||
after: { source: input.source, endDate: input.endDateKey, trialEndsAt: tenant.trialEndsAt, deletionDueAt: tenant.deletionDueAt, modules, sampleData },
|
||||
});
|
||||
|
||||
return {
|
||||
tenantId: tenant.id,
|
||||
tenantSlug: tenant.slug,
|
||||
adminUserId: provisioned.adminUserId,
|
||||
identityId: provisioned.identityId,
|
||||
identityCreated: provisioned.identityCreated,
|
||||
sampleData,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { ROLE_DEFS } from "@/server/rbac";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { createCustomer } from "@/server/services/customers/customers";
|
||||
import { createSite } from "@/server/services/sites/sites";
|
||||
import { createTeam } from "@/server/services/teams/teams";
|
||||
import { ensureDefaultOrderTypes } from "@/server/services/work-orders/settings";
|
||||
import { createWorkOrder } from "@/server/services/work-orders/create";
|
||||
import { assignWorkOrder } from "@/server/services/work-orders/assign";
|
||||
|
||||
/**
|
||||
* L15 Testphase: compact sample data for a new trial tenant (customers, sites, one team, work
|
||||
* orders in several states). Same approach as the demo seed (scripts/lib/demo-seed.ts): every
|
||||
* record is created through the REAL domain services (numbering, audit, status history). The demo
|
||||
* seed itself needs seven users with fixed roles and a PDF/photo pipeline; a trial tenant starts
|
||||
* with ONE administrator, so this is a trimmed variant for exactly that situation.
|
||||
* All names and addresses are fictitious.
|
||||
*/
|
||||
|
||||
export const SAMPLE_ORDER_PREFIX = "BEISPIEL-";
|
||||
export const SAMPLE_TEAM_NAME = "Beispielteam";
|
||||
const SAMPLE_NOTE = "Beispieldaten der Testphase – können jederzeit gelöscht werden.";
|
||||
|
||||
function inDays(days: number, hour: number): Date {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
d.setHours(hour, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
export async function seedTrialSampleData(tenantId: string, adminUserId: string): Promise<{ customers: number; sites: number; teams: number; orders: number }> {
|
||||
const ctx: ServiceCtx = { db: dbForTenant(tenantId), tenantId, userId: adminUserId, permissions: new Set<string>(ROLE_DEFS["tenant-admin"].permissions) };
|
||||
|
||||
await ensureDefaultOrderTypes(ctx.db, tenantId);
|
||||
const types = new Map((await ctx.db.orderType.findMany({ select: { id: true, key: true } })).map((t) => [t.key, t.id]));
|
||||
const type = (key: string) => types.get(key) ?? null;
|
||||
|
||||
const customers = [
|
||||
{ companyName: "Hausverwaltung Musterhof GmbH", street: "Lindenstraße", houseNumber: "12", postalCode: "10969", city: "Berlin", phone: "030 555 0100", email: "service@musterhof.example" },
|
||||
{ companyName: "Bäckerei Sonnenschein", street: "Marktplatz", houseNumber: "3", postalCode: "04109", city: "Leipzig", phone: "0341 555 0200" },
|
||||
{ firstName: "Erika", lastName: "Beispiel", street: "Am Wiesengrund", houseNumber: "7", postalCode: "30159", city: "Hannover", phone: "0511 555 0300", email: "erika.beispiel@example.org" },
|
||||
];
|
||||
const customerIds: string[] = [];
|
||||
for (const c of customers) {
|
||||
customerIds.push((await createCustomer(ctx, { ...c, notes: SAMPLE_NOTE }, { acknowledgeDuplicates: true })).id);
|
||||
}
|
||||
|
||||
const sites = [
|
||||
{ customerId: customerIds[0], name: "Wohnanlage Musterhof – Haus 1", street: "Lindenstraße", houseNumber: "12a", postalCode: "10969", city: "Berlin", accessNotes: "Schlüssel beim Hausmeister (Erdgeschoss links)." },
|
||||
{ customerId: customerIds[1], name: "Backstube", street: "Marktplatz", houseNumber: "3", postalCode: "04109", city: "Leipzig", accessNotes: "Lieferanteneingang hinten, ab 5 Uhr besetzt." },
|
||||
{ customerId: customerIds[2], name: "Einfamilienhaus Am Wiesengrund", street: "Am Wiesengrund", houseNumber: "7", postalCode: "30159", city: "Hannover", accessNotes: "Kundin ist vormittags zu Hause." },
|
||||
];
|
||||
const siteIds: string[] = [];
|
||||
for (const s of sites) siteIds.push((await createSite(ctx, s)).id);
|
||||
|
||||
const team = await createTeam(ctx, { name: SAMPLE_TEAM_NAME, leaderUserId: adminUserId, members: [{ userId: adminUserId }], notes: SAMPLE_NOTE });
|
||||
|
||||
await createWorkOrder(ctx, { title: "Heizungswartung vor der Heizperiode", customerId: customerIds[0], siteId: siteIds[0], orderTypeId: type("wartung"), status: "draft", externalOrderNumber: `${SAMPLE_ORDER_PREFIX}01`, plannedStart: inDays(5, 8), plannedEnd: inDays(5, 12), description: SAMPLE_NOTE });
|
||||
await createWorkOrder(ctx, { title: "Wasserenthärtung prüfen", customerId: customerIds[1], siteId: siteIds[1], orderTypeId: type("wartung"), status: "planned", externalOrderNumber: `${SAMPLE_ORDER_PREFIX}02`, plannedStart: inDays(2, 7), plannedEnd: inDays(2, 9), materials: [{ name: "Filterkartusche", plannedQuantity: 1, unit: "Stk" }] });
|
||||
const assigned = await createWorkOrder(ctx, { title: "Heizkörper im Wohnzimmer tauschen", customerId: customerIds[2], siteId: siteIds[2], orderTypeId: type("montage"), status: "planned", externalOrderNumber: `${SAMPLE_ORDER_PREFIX}03`, plannedStart: inDays(1, 8), plannedEnd: inDays(1, 15), materials: [{ name: "Flachheizkörper 22/600/1000", plannedQuantity: 1, unit: "Stk" }] });
|
||||
await assignWorkOrder(ctx, { workOrderId: assigned.id, teamId: team.id, userIds: [adminUserId] });
|
||||
await createWorkOrder(ctx, { title: "Undichte Leitung im Keller", customerId: customerIds[0], siteId: siteIds[0], orderTypeId: type("reparatur"), priority: "high", status: "review_required", externalOrderNumber: `${SAMPLE_ORDER_PREFIX}04`, description: "Aus einer Kundenmail übernommen – bitte prüfen." });
|
||||
|
||||
return { customers: customerIds.length, sites: siteIds.length, teams: 1, orders: 4 };
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { createHash, createHmac, randomBytes } from "node:crypto";
|
||||
import { prisma } from "@/server/db";
|
||||
import { writePlatformAudit } from "@/server/audit";
|
||||
import { hashPassword } from "@/server/password";
|
||||
import { absoluteUrl } from "@/server/mail/config";
|
||||
import { enqueueMail } from "@/server/mail/service";
|
||||
import { formatWhen, normalizeLocale } from "@/server/mail/templates";
|
||||
import { formatDateKey, trialBounds, type TrialBounds } from "@/lib/trial/dates";
|
||||
import { normalizeTrialValues, TRIAL_STEPS, validateTrialSignup, validateTrialStep, type FieldErrors, type TrialStep } from "@/lib/trial/signup";
|
||||
import { trialMaxDays, TRIAL_DEFAULT_DAYS, TRIAL_SIGNUP_TOKEN_TTL_MS } from "./config";
|
||||
import { provisionTrialTenant } from "./provision";
|
||||
|
||||
/**
|
||||
* L15 Testphase: public self-service signup with double opt-in.
|
||||
*
|
||||
* - Nothing is provisioned before the e-mail address is confirmed; the pending signup lives in
|
||||
* `TrialSignup` (platform table) with the Argon2id(+pepper) password hash and the SHA-256 of the
|
||||
* link token — never the raw password/token. The IP is stored only as HMAC.
|
||||
* - Enumeration protection: an address that already has an account gets exactly the same response
|
||||
* (and the same work: the password is hashed in both branches), but a hint mail instead of a
|
||||
* confirmation link.
|
||||
* - Rate limits and the honeypot check live in the action adapter (src/server/actions/trial-signup.ts
|
||||
* via ./abuse.ts), so this service stays testable without a request.
|
||||
*/
|
||||
|
||||
export type SendMail = typeof enqueueMail;
|
||||
|
||||
export type SignupResult = { status: "sent" } | { status: "invalid"; errors: FieldErrors };
|
||||
|
||||
export type ConfirmResult =
|
||||
| { status: "ok"; tenantId: string; tenantSlug: string; identityId: string }
|
||||
| { status: "invalid" }
|
||||
| { status: "expired" };
|
||||
|
||||
export function hashSignupToken(raw: string): string {
|
||||
return createHash("sha256").update(raw).digest("hex");
|
||||
}
|
||||
|
||||
export function hashIp(ip: string | null | undefined): string | null {
|
||||
if (!ip) return null;
|
||||
return createHmac("sha256", process.env.AUTH_SECRET ?? "craftvia-trial").update(ip).digest("hex");
|
||||
}
|
||||
|
||||
export function currentTrialBounds(now: Date = new Date()): TrialBounds {
|
||||
return trialBounds(now, trialMaxDays(), TRIAL_DEFAULT_DAYS);
|
||||
}
|
||||
|
||||
/** Server-side validation of one wizard step (no writes). Unknown step → form error. */
|
||||
export function checkTrialStep(step: string, raw: unknown, now: Date = new Date()): FieldErrors {
|
||||
if (!(TRIAL_STEPS as readonly string[]).includes(step)) return { _form: "invalid_request" };
|
||||
const values = normalizeTrialValues(raw);
|
||||
if (!values) return { _form: "invalid_request" };
|
||||
return validateTrialStep(step as TrialStep, values, currentTrialBounds(now));
|
||||
}
|
||||
|
||||
export async function submitTrialSignup(
|
||||
raw: unknown,
|
||||
opts: { ip?: string | null; now?: Date; locale?: string | null; sendMail?: SendMail } = {},
|
||||
): Promise<SignupResult> {
|
||||
const now = opts.now ?? new Date();
|
||||
const send = opts.sendMail ?? enqueueMail;
|
||||
const locale = normalizeLocale(opts.locale);
|
||||
const values = normalizeTrialValues(raw);
|
||||
if (!values) return { status: "invalid", errors: { _form: "invalid_request" } };
|
||||
// Honeypot filled → pretend success, do nothing.
|
||||
if (values.website.trim() !== "") return { status: "sent" };
|
||||
|
||||
const errors = validateTrialSignup(values, currentTrialBounds(now));
|
||||
if (Object.keys(errors).length > 0) return { status: "invalid", errors };
|
||||
|
||||
// Hash in BOTH branches — equal work, no timing oracle for existing addresses.
|
||||
const passwordHash = await hashPassword(values.password);
|
||||
const existing = await prisma.identity.findUnique({ where: { email: values.email }, select: { id: true } });
|
||||
if (existing) {
|
||||
const hourBucket = Math.floor(now.getTime() / 3_600_000);
|
||||
await send({
|
||||
template: "trial_existing_account",
|
||||
to: values.email,
|
||||
tenantId: null,
|
||||
locale,
|
||||
vars: { name: values.adminName, loginUrl: absoluteUrl("/login"), resetUrl: absoluteUrl("/forgot-password") },
|
||||
// at most one hint mail per address per hour
|
||||
dedupeKey: `trial_existing:${hashSignupToken(values.email)}:${hourBucket}`,
|
||||
});
|
||||
await writePlatformAudit({ action: "denied", entity: "trial_signup", after: { reason: "existing_account" } });
|
||||
return { status: "sent" };
|
||||
}
|
||||
|
||||
const rawToken = randomBytes(32).toString("base64url");
|
||||
const expiresAt = new Date(now.getTime() + TRIAL_SIGNUP_TOKEN_TTL_MS);
|
||||
// A new request makes older open links of the same address useless.
|
||||
await prisma.trialSignup.updateMany({ where: { email: values.email, status: "pending" }, data: { status: "superseded", passwordHash: "" } });
|
||||
const signup = await prisma.trialSignup.create({
|
||||
data: {
|
||||
companyName: values.companyName,
|
||||
sector: values.sector || null,
|
||||
companySize: values.companySize || null,
|
||||
adminName: values.adminName,
|
||||
email: values.email,
|
||||
passwordHash,
|
||||
trialEndDate: values.trialEndDate,
|
||||
sampleData: values.sampleData,
|
||||
modules: values.modules,
|
||||
locale,
|
||||
tokenHash: hashSignupToken(rawToken),
|
||||
expiresAt,
|
||||
ipHash: hashIp(opts.ip),
|
||||
acceptedTermsAt: now,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await send({
|
||||
template: "trial_confirm",
|
||||
to: values.email,
|
||||
tenantId: null,
|
||||
locale,
|
||||
vars: {
|
||||
name: values.adminName,
|
||||
companyName: values.companyName,
|
||||
trialEnd: formatDateKey(values.trialEndDate, locale),
|
||||
actionUrl: absoluteUrl(`/testen/bestaetigen?token=${encodeURIComponent(rawToken)}`),
|
||||
expires: formatWhen(expiresAt, locale),
|
||||
},
|
||||
});
|
||||
await writePlatformAudit({ action: "create", entity: "trial_signup", entityId: signup.id, after: { trialEndDate: values.trialEndDate, sampleData: values.sampleData, modules: values.modules.length } });
|
||||
return { status: "sent" };
|
||||
}
|
||||
|
||||
/** Shows the pending signup behind a link (without consuming it). */
|
||||
export async function peekTrialSignup(rawToken: string, now: Date = new Date()) {
|
||||
if (!rawToken || rawToken.length > 200) return null;
|
||||
const row = await prisma.trialSignup.findUnique({
|
||||
where: { tokenHash: hashSignupToken(rawToken) },
|
||||
select: { status: true, expiresAt: true, companyName: true, trialEndDate: true, sampleData: true },
|
||||
});
|
||||
if (!row || row.status !== "pending" || row.expiresAt <= now) return null;
|
||||
return { companyName: row.companyName, trialEndDate: row.trialEndDate, sampleData: row.sampleData };
|
||||
}
|
||||
|
||||
/** Consumes the link (single use, 24 h) and provisions the trial tenant. */
|
||||
export async function confirmTrialSignup(rawToken: string, opts: { now?: Date } = {}): Promise<ConfirmResult> {
|
||||
const now = opts.now ?? new Date();
|
||||
if (!rawToken || rawToken.length > 200) return { status: "invalid" };
|
||||
const row = await prisma.trialSignup.findUnique({ where: { tokenHash: hashSignupToken(rawToken) } });
|
||||
if (!row || row.status !== "pending") return { status: "invalid" };
|
||||
if (row.expiresAt <= now) {
|
||||
await prisma.trialSignup.updateMany({ where: { id: row.id, status: "pending" }, data: { status: "expired", passwordHash: "" } });
|
||||
return { status: "expired" };
|
||||
}
|
||||
// Claim: only one of several parallel clicks wins.
|
||||
const claim = await prisma.trialSignup.updateMany({ where: { id: row.id, status: "pending" }, data: { status: "confirming" } });
|
||||
if (claim.count !== 1) return { status: "invalid" };
|
||||
|
||||
// The address may have got an account in the meantime (invitation, other signup).
|
||||
if (await prisma.identity.findUnique({ where: { email: row.email }, select: { id: true } })) {
|
||||
await prisma.trialSignup.update({ where: { id: row.id }, data: { status: "existing_account", passwordHash: "" } });
|
||||
await writePlatformAudit({ action: "denied", entity: "trial_signup", entityId: row.id, after: { reason: "existing_account" } });
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await provisionTrialTenant({
|
||||
companyName: row.companyName,
|
||||
sector: row.sector,
|
||||
companySize: row.companySize,
|
||||
admin: { name: row.adminName, email: row.email, passwordHash: row.passwordHash },
|
||||
endDateKey: row.trialEndDate,
|
||||
sampleData: row.sampleData,
|
||||
modules: row.modules,
|
||||
source: "self_signup",
|
||||
now,
|
||||
});
|
||||
await prisma.trialSignup.update({
|
||||
where: { id: row.id },
|
||||
data: { status: "confirmed", confirmedAt: now, provisionedTenantId: result.tenantId, passwordHash: "" },
|
||||
});
|
||||
await writePlatformAudit({ action: "update", entity: "trial_signup", entityId: row.id, after: { status: "confirmed", tenantId: result.tenantId } });
|
||||
return { status: "ok", tenantId: result.tenantId, tenantSlug: result.tenantSlug, identityId: result.identityId };
|
||||
} catch (err) {
|
||||
await prisma.trialSignup.update({ where: { id: row.id }, data: { status: "failed", passwordHash: "" } });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { prisma } from "@/server/db";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { diffDayKeys, todayKey, trialEndDateKey } from "@/lib/trial/dates";
|
||||
import { TRIAL_BANNER_DAYS } from "./config";
|
||||
|
||||
/**
|
||||
* L15 Testphase: lifecycle state of a tenant and the CENTRAL write lock.
|
||||
*
|
||||
* After the chosen end the trial tenant is read-only: every mutation path calls
|
||||
* `assertTenantWritable(tenantId)` (moduleGuard, requireApiContext for non-GET /api/v1 requests,
|
||||
* backoffice upload route, settings/user administration). Reads, file downloads, PDFs, the data
|
||||
* export and the login stay possible. The lock depends only on `trialEndsAt` — not on the daily
|
||||
* job — so it applies to the second.
|
||||
*
|
||||
* `Tenant` is a platform table (no tenant_id / RLS); it is read via the owner client by id.
|
||||
*/
|
||||
|
||||
export type TenantTrialRow = {
|
||||
plan: "FULL" | "TRIAL";
|
||||
trialStartedAt: Date | null;
|
||||
trialEndsAt: Date | null;
|
||||
convertedAt: Date | null;
|
||||
readOnlySince: Date | null;
|
||||
deletionDueAt: Date | null;
|
||||
trialDeletedAt?: Date | null;
|
||||
};
|
||||
|
||||
export type TrialState = {
|
||||
isTrial: boolean;
|
||||
endsAt: Date | null;
|
||||
/** Last trial day (YYYY-MM-DD, Berlin). */
|
||||
endDateKey: string | null;
|
||||
/** Calendar days until the last trial day (0 = ends today); null without trial. */
|
||||
daysLeft: number | null;
|
||||
expired: boolean;
|
||||
readOnly: boolean;
|
||||
deletionDueAt: Date | null;
|
||||
/** Show "trial ends in X days" (from TRIAL_BANNER_DAYS on). */
|
||||
showCountdown: boolean;
|
||||
};
|
||||
|
||||
export const TRIAL_SELECT = {
|
||||
plan: true,
|
||||
trialStartedAt: true,
|
||||
trialEndsAt: true,
|
||||
convertedAt: true,
|
||||
readOnlySince: true,
|
||||
deletionDueAt: true,
|
||||
trialDeletedAt: true,
|
||||
} as const;
|
||||
|
||||
export function computeTrialState(row: TenantTrialRow | null, now: Date = new Date()): TrialState {
|
||||
if (!row || row.plan !== "TRIAL" || !row.trialEndsAt) {
|
||||
return { isTrial: false, endsAt: null, endDateKey: null, daysLeft: null, expired: false, readOnly: false, deletionDueAt: null, showCountdown: false };
|
||||
}
|
||||
const endDateKey = trialEndDateKey(row.trialEndsAt);
|
||||
const expired = now.getTime() >= row.trialEndsAt.getTime();
|
||||
const daysLeft = diffDayKeys(todayKey(now), endDateKey);
|
||||
return {
|
||||
isTrial: true,
|
||||
endsAt: row.trialEndsAt,
|
||||
endDateKey,
|
||||
daysLeft,
|
||||
expired,
|
||||
readOnly: expired,
|
||||
deletionDueAt: row.deletionDueAt,
|
||||
showCountdown: !expired && daysLeft <= TRIAL_BANNER_DAYS,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTrialState(tenantId: string, now: Date = new Date()): Promise<TrialState> {
|
||||
const row = await prisma.tenant.findUnique({ where: { id: tenantId }, select: TRIAL_SELECT });
|
||||
return computeTrialState(row, now);
|
||||
}
|
||||
|
||||
/** Plain-text reason for API clients and legacy action forms that show `err.message`-style texts. */
|
||||
export const TRIAL_READ_ONLY_MESSAGE = "Testphase abgelaufen – nur Lesezugriff. Export und Downloads bleiben möglich.";
|
||||
|
||||
/** `blocked trial_expired` — mapped to HTTP 422 by respond.ts, shown as plain text by the UI. */
|
||||
export class TrialExpiredError extends ServiceError {
|
||||
constructor(deletionDueAt: Date | null) {
|
||||
super("blocked", "trial_expired", {
|
||||
reason: "trial_expired",
|
||||
readOnly: true,
|
||||
deletionDueAt: deletionDueAt?.toISOString() ?? null,
|
||||
message: TRIAL_READ_ONLY_MESSAGE,
|
||||
});
|
||||
this.name = "TrialExpiredError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isTrialExpiredError(err: unknown): boolean {
|
||||
return err instanceof ServiceError && err.code === "blocked" && err.message === "trial_expired";
|
||||
}
|
||||
|
||||
/** Throws `TrialExpiredError` when the tenant is an expired, unconverted trial. */
|
||||
export async function assertTenantWritable(tenantId: string, now: Date = new Date()): Promise<void> {
|
||||
const state = await getTrialState(tenantId, now);
|
||||
if (state.readOnly) throw new TrialExpiredError(state.deletionDueAt);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { DeleteObjectsCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
|
||||
|
||||
/**
|
||||
* L15 Testphase: remove ALL objects of a tenant from the document/upload bucket (`<tenantId>/…`).
|
||||
*
|
||||
* The upload adapter (src/server/storage/adapter.ts, foundation) only offers put/get; the DSGVO
|
||||
* offboarding removes `<tenantId>/uploads/` from the BACKUP store, which is not necessarily the
|
||||
* upload bucket (backup target can be local or a different bucket). Same S3_* configuration as the
|
||||
* upload adapter; without S3 (stub storage) there are no bytes to delete.
|
||||
*/
|
||||
export async function purgeTenantObjects(tenantId: string): Promise<number> {
|
||||
if (!/^[A-Za-z0-9_-]{8,}$/.test(tenantId)) throw new Error("purgeTenantObjects: invalid tenant id");
|
||||
const endpoint = process.env.S3_ENDPOINT?.trim();
|
||||
const accessKeyId = process.env.S3_ACCESS_KEY?.trim();
|
||||
const secretAccessKey = process.env.S3_SECRET_KEY?.trim();
|
||||
const bucket = process.env.S3_BUCKET?.trim();
|
||||
if (!endpoint || !accessKeyId || !secretAccessKey || !bucket) return 0;
|
||||
|
||||
const client = new S3Client({ endpoint, region: process.env.S3_REGION?.trim() || "us-east-1", forcePathStyle: true, credentials: { accessKeyId, secretAccessKey } });
|
||||
const prefix = `${tenantId}/`;
|
||||
let removed = 0;
|
||||
let token: string | undefined;
|
||||
do {
|
||||
const page = await client.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, ContinuationToken: token }));
|
||||
const keys = (page.Contents ?? []).map((o) => o.Key).filter((k): k is string => !!k && k.startsWith(prefix));
|
||||
if (keys.length) {
|
||||
await client.send(new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: keys.map((Key) => ({ Key })), Quiet: true } }));
|
||||
removed += keys.length;
|
||||
}
|
||||
token = page.IsTruncated ? page.NextContinuationToken : undefined;
|
||||
} while (token);
|
||||
return removed;
|
||||
}
|
||||
Reference in New Issue
Block a user