Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/demo-pro-seed/persistence.ts
T

2259 lines
89 KiB
TypeScript

import { type DbClient } from "@hivegrid/db/client.server";
import { publishOutboxEvents, type OutboxPublishArgs } from "@hivegrid/db/outbox";
import { syncDefaultRolesAndPermissions } from "@hivegrid/db/permissions-seed.server";
import { Prisma } from "@hivegrid/db/prisma-browser";
import { seedShkTemplateRolesForTenant, shkTemplateRoleSeeds } from "@hivegrid/db/shk-template-roles-seed.server";
import { TENANT_SCOPED_MODELS } from "../../packages/cli/src/commands/tenant/models";
import {
ALL_SEARCH_INDEXES,
crmContactsIndex,
filesIndex,
projectsIndex,
sanitizeDocument,
searchEventType,
tasksIndex,
usersIndex,
type SearchIndexSchema,
} from "../../packages/search/src/index";
import { hashCurrentPassword } from "../../apps/api/src/auth/password";
import { OPERATIONAL_COUNTS, type DemoProOperationalGraph } from "./operations";
import { seedFileStorageKey } from "./file-objects";
import { splitSearchPayload } from "./search-batches";
import type { DemoProSeedGraph, SeedAddress } from "./types";
const TENANT_USER_TYPE = { OWNER: 0, MEMBER: 2 } as const;
const TENANT_USER_JOINED_CREATOR = 0;
const TENANT_USER_JOINED_MEMBER = 1;
const TENANT_USER_STATUS_ACTIVE = 2;
const CREATE_BATCH_SIZE = 250;
const SEED_LOCK_TTL_MS = 30 * 60_000;
const SEED_LOCK_HEARTBEAT_MS = 60_000;
const STAGING_COMPANY_NAME = "RheinMain Gebäudetechnik GmbH";
const STAGING_LOST_REASONS = [
"Preis",
"Zeitpunkt",
"Kein Bedarf",
"Wettbewerber",
"Nicht erreichbar",
"Preisentscheidung",
"Zeitfenster nicht passend",
"Bestandsanlage wird weiterbetrieben",
"Wettbewerber beauftragt",
] as const;
// The managed staging tenant must behave like a normal workspace. Its guarded
// reset intentionally replaces all tenant business data and does not rely on
// product-level demo flags for lifecycle ownership.
const STAGING_BUSINESS_DATA_IS_DEMO = false;
const TENANT_MODELS_WITH_DEMO_FLAG = [
"AbnahmeProtokoll",
"AcceptanceFormTemplate",
"Appointment",
"AutomationExecution",
"AutomationRule",
"AutomationRuleStep",
"AutomationTemplate",
"BookingPage",
"CalendarConflict",
"CalendarConnection",
"CalendarEvent",
"Campaign",
"CustomFieldDefinition",
"Dashboard",
"DashboardLayout",
"DataConnection",
"EmailIntegrationMailbox",
"EmailIntegrationMessage",
"EmailSender",
"EmailTemplate",
"EmployeePasswordSafeEntry",
"EmployeePasswordSafeVault",
"EmployeePayslip",
"EmployeeSickLeaveReport",
"EmployeeUploadReminder",
"EmployeeVacationRequest",
"File",
"Form",
"HrAnnouncement",
"HrEmployeeDocument",
"HrEmployeeProfile",
"HrLocation",
"HrQuickLink",
"HrRequest",
"HrRequestTemplate",
"HrResource",
"HrSurvey",
"InboxLead",
"Kontakt",
"LeadAntiSpamConfig",
"LeadSource",
"LostReason",
"Pipeline",
"PipelineFieldGroup",
"PipelineStage",
"PortalActivityRoutingRule",
"PortalComplaint",
"PortalConfiguration",
"PortalCustomDomain",
"PortalDocumentAutoReleaseRule",
"PortalDomainHostnameClaim",
"PortalEmailTemplate",
"PortalProjectAccess",
"PortalProjectDocument",
"PortalServiceInboxItem",
"PortalStageVisibility",
"PortalSubcontractorAccess",
"PortalUpload",
"PortalUser",
"ProjectTemplate",
"Projekt",
"ProjektChecklist",
"ProjektNotiz",
"ProjektSignatureDocument",
"ProjektTermin",
"PublicIncomingWebhookEndpoint",
"PublicWebhookSubscription",
"Rechnung",
"RecipientSegment",
"ScheduleEntry",
"SearchIndexEntry",
"SignatureProviderConnection",
"Task",
"TeamChat",
"TeamChatMessage",
"TelephonyCallSession",
"TenantInboundAddress",
"TenantSendingDomain",
"WidgetInstance",
] as const;
const PRESERVED_TENANT_MODELS = new Set([
"AdminTenantNote",
"AuditChainCheckpoint",
"AuditChainCheckpointHead",
"AuditChainHead",
"AuditEvent",
"AvvAcceptance",
"Credit",
"ImpersonationAudit",
"OutboxEvent",
"Role",
"TenantDeletionRequest",
"TenantEncryptionKey",
"TenantFeatureOverride",
"TenantProductConfiguration",
"TenantSubscription",
"TenantSubscriptionUsageRecord",
]);
type TenantRow = { id: string; slug: string; name: string; active: boolean };
type ResetTenantRow = TenantRow & { previousMemberIds: string[] };
export type DemoProSeedLease = {
tenantId: string;
tenantSlug: string;
lockId: string;
token: string;
};
export type DemoProSeedLeaseHeartbeat = {
assertHealthy(): Promise<void>;
stop(): Promise<void>;
};
type SeedEmployeeIdentity = { id: string; email: string };
type SearchProjection = {
row: Prisma.SearchIndexEntryCreateManyInput;
event: OutboxPublishArgs;
};
export type SeedVerificationReport = {
tenantSlug: string;
counts: Record<string, number>;
pendingSearchEvents: number;
failedSearchEvents: number;
valid: boolean;
errors: string[];
};
export type DemoProVerificationOptions = {
graph: DemoProSeedGraph;
operational: DemoProOperationalGraph;
expectTenantActive: boolean;
};
function chunks<T>(values: readonly T[], size = CREATE_BATCH_SIZE): T[][] {
const result: T[][] = [];
for (let index = 0; index < values.length; index += size) result.push(values.slice(index, index + size));
return result;
}
function prismaModelAccessor(model: string): string {
return `${model.charAt(0).toLowerCase()}${model.slice(1)}`;
}
type DeleteManyDelegate = {
deleteMany(args: { where: { tenantId: string } }): Promise<unknown>;
};
type CountDemoRowsDelegate = {
count(args: { where: { tenantId: string; isDemoData: boolean } }): Promise<number>;
};
function tenantDeleteDelegate(tx: Prisma.TransactionClient, model: string): DeleteManyDelegate {
const delegate = (tx as unknown as Record<string, unknown>)[prismaModelAccessor(model)] as DeleteManyDelegate | undefined;
if (!delegate?.deleteMany) throw new Error(`demo-pro seed: Prisma delegate missing for tenant model ${model}`);
return delegate;
}
function tenantDemoRowsDelegate(db: DbClient, model: string): CountDemoRowsDelegate {
const delegate = (db as unknown as Record<string, unknown>)[prismaModelAccessor(model)] as CountDemoRowsDelegate | undefined;
if (!delegate?.count) throw new Error(`demo-pro seed: Prisma count delegate missing for demo-aware tenant model ${model}`);
return delegate;
}
async function withTenantTransaction<T>(db: DbClient, tenantId: string, callback: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
return db.$transaction(
async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
return callback(tx);
},
{ maxWait: 15_000, timeout: 180_000 }
);
}
function addressKey(address: SeedAddress): string {
return [address.land, address.plz, address.ort, address.strasse, address.hausnummer].join("|");
}
function buildAddresses(graph: DemoProSeedGraph): { rows: Prisma.AdresseCreateManyInput[]; idByKey: Map<string, string> } {
const idByKey = new Map<string, string>();
const rows: Prisma.AdresseCreateManyInput[] = [];
const addresses = [
...graph.organizations.map(({ address }) => address),
...graph.persons.map(({ address }) => address),
...graph.projects.flatMap(({ projectAddress, invoiceAddress, clientAddress }) => [projectAddress, invoiceAddress, clientAddress]),
];
for (const address of addresses) {
const key = addressKey(address);
if (idByKey.has(key)) continue;
const id = `${graph.namespace}-address-${String(rows.length + 1).padStart(5, "0")}`;
idByKey.set(key, id);
rows.push({ id, tenantId: "", strasse: address.strasse, hausnummer: address.hausnummer, plz: address.plz, ort: address.ort, land: address.land });
}
return { rows, idByKey };
}
function stageId(graph: DemoProSeedGraph, pipeline: "sales" | "auftrag", stage: string): string {
return `${graph.namespace}-stage-${pipeline}-${stage}`;
}
function pipelineId(graph: DemoProSeedGraph, pipeline: "sales" | "auftrag"): string {
return `${graph.namespace}-pipeline-${pipeline}`;
}
function iso(value: string | null): Date | null {
return value ? new Date(value) : null;
}
function slug(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]+/gu, "-")
.replace(/^-|-$/gu, "");
}
async function findTenant(db: DbClient, tenantSlug: string): Promise<TenantRow> {
const tenant = await db.tenant.findUnique({
where: { slug: tenantSlug },
select: { id: true, slug: true, name: true, active: true },
});
if (!tenant) throw new Error(`demo-pro seed: tenant ${tenantSlug} does not exist`);
return tenant;
}
function searchProjection(input: {
schema: SearchIndexSchema;
tenantId: string;
entityType: string;
entityId: string;
title: string;
subtitle?: string | null;
bodyText: string;
routePath: string;
permissionSubject: string;
document: Record<string, unknown> & { id: string; tenantId: string };
}): SearchProjection {
const document = sanitizeDocument(input.schema, input.document);
return {
row: {
id: `rheinmain-shk-v1-search-${input.entityType}-${input.entityId}`,
tenantId: input.tenantId,
entityType: input.entityType,
entityId: input.entityId,
title: input.title,
subtitle: input.subtitle ?? null,
bodyText: input.bodyText,
routePath: input.routePath,
sourceIndex: input.schema.name,
permissionSubject: input.permissionSubject,
isArchived: false,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
metadata: { importBatch: "rheinmain-shk-v1" },
},
event: {
tenantId: input.tenantId,
type: searchEventType(input.entityType, "upsert"),
payload: { action: "upsert", indexName: input.schema.name, tenantId: input.tenantId, document } as Prisma.InputJsonValue,
},
};
}
async function writeSearchProjections(tx: Prisma.TransactionClient, projections: readonly SearchProjection[]): Promise<void> {
if (projections.length === 0) return;
for (const batch of chunks(projections.map(({ row }) => row))) await tx.searchIndexEntry.createMany({ data: batch, skipDuplicates: true });
const byIndex = new Map<string, Array<Record<string, unknown> & { id: string | number }>>();
for (const { event } of projections) {
const payload = event.payload as { indexName: string; document: Record<string, unknown> & { id: string | number } };
const documents = byIndex.get(payload.indexName) ?? [];
documents.push(payload.document);
byIndex.set(payload.indexName, documents);
}
const events = [...byIndex.entries()].flatMap(([indexName, documents]) =>
splitSearchPayload(documents, (batch) => ({ action: "upsert-batch", indexName, tenantId: projections[0]!.row.tenantId, documents: batch })).map(
(batch): OutboxPublishArgs => ({
tenantId: projections[0]!.row.tenantId,
type: `search.index.${indexName.toLowerCase().replace(/[^a-z0-9]/gu, "")}.upsertbatch`,
payload: { action: "upsert-batch", indexName, tenantId: projections[0]!.row.tenantId, documents: batch } as Prisma.InputJsonValue,
})
)
);
await publishOutboxEvents(tx, events, { batchSize: 100 });
}
function clearSearchEvents(tenantId: string): OutboxPublishArgs[] {
return ALL_SEARCH_INDEXES.map(({ name: indexName }) => ({
tenantId,
type: `search.index.${indexName.toLowerCase().replace(/[^a-z0-9]/gu, "")}.cleartenant`,
payload: { action: "clear-tenant", indexName, tenantId },
}));
}
export async function beginDemoProSeedRun(
db: DbClient,
tenantSlug: string,
expectedTenantId: string,
seedEmployees: readonly SeedEmployeeIdentity[]
): Promise<DemoProSeedLease> {
const tenant = await findTenant(db, tenantSlug);
if (tenant.id !== expectedTenantId) throw new Error("demo-pro seed: target tenant does not match the configured staging tenant binding");
const seedEmployeeIds = seedEmployees.map(({ id }) => id);
const seedEmployeeEmails = seedEmployees.map(({ email }) => email);
const existingSeedUsers = await db.user.findMany({
where: { OR: [{ id: { in: seedEmployeeIds } }, { email: { in: seedEmployeeEmails } }] },
select: { id: true, email: true },
});
const expectedEmailById = new Map(seedEmployees.map(({ id, email }) => [id, email]));
const expectedIdByEmail = new Map(seedEmployees.map(({ id, email }) => [email, id]));
if (
existingSeedUsers.some(
(user) =>
(expectedEmailById.has(user.id) && expectedEmailById.get(user.id) !== user.email) ||
(expectedIdByEmail.has(user.email) && expectedIdByEmail.get(user.email) !== user.id)
)
) {
throw new Error("demo-pro seed: deterministic seed user IDs and e-mail addresses do not match the existing global users");
}
const externalMembership = await db.tenantUser.findFirst({
where: { userId: { in: [...seedEmployeeIds] }, tenantId: { not: tenant.id } },
select: { userId: true },
});
if (externalMembership) throw new Error("demo-pro seed: a deterministic seed user belongs to another tenant");
const privilegedOrDeletingUser = await db.user.findFirst({
where: {
id: { in: [...seedEmployeeIds] },
OR: [{ admin: { isNot: null } }, { roles: { some: { tenantId: null } } }, { pendingDeletion: true }, { pseudonymized: true }],
},
select: { id: true },
});
if (privilegedOrDeletingUser) throw new Error("demo-pro seed: a deterministic seed user has global privileges or an incompatible account state");
const lockId = `demo-pro-seed-lock-${tenant.id}`;
const token = globalThis.crypto.randomUUID();
const lockedUntil = new Date(Date.now() + SEED_LOCK_TTL_MS);
await db.$transaction(async (tx) => {
const inserted = await tx.outboxEvent.createMany({
data: [
{
id: lockId,
tenantId: tenant.id,
eventType: "seed.demo-pro.lock",
payload: { tenantSlug, token },
status: "PROCESSING",
dispatchKey: lockId,
lastError: token,
lockedUntil,
},
],
skipDuplicates: true,
});
if (inserted.count === 0) {
const takeover = await tx.outboxEvent.updateMany({
where: { id: lockId, lockedUntil: { lt: new Date() } },
data: {
payload: { tenantSlug, token },
status: "PROCESSING",
lastError: token,
lockedUntil,
processedAt: null,
},
});
if (takeover.count !== 1) throw new Error("demo-pro seed: another seed or cleanup run currently holds the tenant lease");
}
await tx.tenant.update({
where: { id: tenant.id },
data: { active: false, deactivatedReason: "demo-pro seed maintenance" },
});
});
return { tenantId: tenant.id, tenantSlug: tenant.slug, lockId, token };
}
export async function renewDemoProSeedLease(db: DbClient, lease: DemoProSeedLease): Promise<void> {
const renewed = await db.outboxEvent.updateMany({
where: { id: lease.lockId, tenantId: lease.tenantId, lastError: lease.token, status: "PROCESSING" },
data: { lockedUntil: new Date(Date.now() + SEED_LOCK_TTL_MS) },
});
if (renewed.count !== 1) throw new Error("demo-pro seed: tenant lease was lost during execution");
}
/**
* Keeps the recoverable database lease alive while the multi-phase seed runs.
* Every renewal is serialized and ownership is proven by the unguessable token.
*/
export function startDemoProSeedLeaseHeartbeat(db: DbClient, lease: DemoProSeedLease, intervalMs = SEED_LOCK_HEARTBEAT_MS): DemoProSeedLeaseHeartbeat {
let stopped = false;
let failure: Error | null = null;
let renewal: Promise<void> | null = null;
const pulse = (): void => {
if (stopped || failure || renewal) return;
renewal = renewDemoProSeedLease(db, lease)
.catch((error: unknown) => {
failure = error instanceof Error ? error : new Error("demo-pro seed: tenant lease heartbeat failed");
})
.finally(() => {
renewal = null;
});
};
pulse();
const timer = setInterval(pulse, Math.max(1_000, Math.min(intervalMs, SEED_LOCK_TTL_MS / 3)));
timer.unref?.();
const assertHealthy = async (): Promise<void> => {
await renewal;
if (failure) throw failure;
};
return {
assertHealthy,
async stop(): Promise<void> {
stopped = true;
clearInterval(timer);
await assertHealthy();
},
};
}
export async function finishDemoProSeedRun(db: DbClient, lease: DemoProSeedLease, activateTenant: boolean): Promise<void> {
await db.$transaction(async (tx) => {
const released = await tx.outboxEvent.deleteMany({ where: { id: lease.lockId, lastError: lease.token, status: "PROCESSING" } });
if (released.count !== 1) throw new Error("demo-pro seed: tenant lease was lost before completion");
await tx.tenant.update({
where: { id: lease.tenantId },
data: { active: activateTenant, deactivatedReason: activateTenant ? null : "demo-pro seed maintenance" },
});
});
}
export async function abandonDemoProSeedRun(db: DbClient, lease: DemoProSeedLease): Promise<void> {
await db.outboxEvent.deleteMany({ where: { id: lease.lockId, lastError: lease.token, status: "PROCESSING" } });
}
async function quiesceTenantOutbox(db: DbClient, tenantId: string, timeoutMs = 30_000): Promise<void> {
const where = { tenantId, eventType: { not: "seed.demo-pro.lock" } } as const;
await db.outboxEvent.updateMany({
where: { ...where, status: { in: ["PENDING", "FAILED"] } },
data: { status: "CANCELLED", lockedUntil: null, processedAt: new Date() },
});
const deadline = Date.now() + timeoutMs;
while (await db.outboxEvent.count({ where: { ...where, status: "PROCESSING" } })) {
if (Date.now() >= deadline) throw new Error("demo-pro seed: timed out while waiting for claimed tenant side effects to finish");
await new Promise((resolve) => setTimeout(resolve, 500));
}
await db.outboxEvent.updateMany({
where: { ...where, status: { in: ["PENDING", "FAILED"] } },
data: { status: "CANCELLED", lockedUntil: null, processedAt: new Date() },
});
}
export async function resetDemoProTenant(db: DbClient, tenantSlug: string, seedEmployeeIds: readonly string[]): Promise<ResetTenantRow> {
const tenant = await findTenant(db, tenantSlug);
await quiesceTenantOutbox(db, tenant.id);
const previousMemberIds = await withTenantTransaction(db, tenant.id, async (tx) => {
const previousMembers = await tx.tenantUser.findMany({ where: { tenantId: tenant.id }, select: { userId: true } });
const knowledgeBases = await tx.knowledgeBase.findMany({ where: { tenantId: tenant.id }, select: { id: true } });
const knowledgeBaseIds = knowledgeBases.map(({ id }) => id);
if (knowledgeBaseIds.length > 0) {
await tx.knowledgeBaseArticle.deleteMany({ where: { knowledgeBaseId: { in: knowledgeBaseIds } } });
await tx.knowledgeBaseCategory.deleteMany({ where: { knowledgeBaseId: { in: knowledgeBaseIds } } });
}
for (const { model } of [...TENANT_SCOPED_MODELS].reverse()) {
if (PRESERVED_TENANT_MODELS.has(model)) continue;
await tenantDeleteDelegate(tx, model).deleteMany({ where: { tenantId: tenant.id } });
}
// Template roles are editable in ordinary tenants. This dedicated managed
// seed, however, must recreate them from the exact catalog so stale manual
// grants can never survive a reset and reach newly generated employees.
await tx.role.deleteMany({
where: {
tenantId: tenant.id,
OR: [{ isTemplateOrigin: true }, { name: { in: shkTemplateRoleSeeds.map((role) => role.name) } }],
},
});
await tx.outboxEvent.deleteMany({ where: { tenantId: tenant.id, eventType: { startsWith: "search.index." }, status: "CANCELLED" } });
await publishOutboxEvents(tx, clearSearchEvents(tenant.id), { batchSize: 100 });
return previousMembers.map(({ userId }) => userId);
});
const seedIds = [...new Set(seedEmployeeIds)];
await db.$transaction(async (tx) => {
await tx.userSession.deleteMany({ where: { userId: { in: seedIds } } });
await tx.trustedDevice.deleteMany({ where: { userId: { in: seedIds } } });
await tx.magicLinkToken.deleteMany({ where: { userId: { in: seedIds } } });
await tx.deviceToken.deleteMany({ where: { userId: { in: seedIds } } });
await tx.user.updateMany({
where: { id: { in: previousMemberIds }, defaultTenantId: tenant.id },
data: { defaultTenantId: null },
});
await tx.user.updateMany({
where: { id: { in: seedIds } },
data: {
active: false,
defaultTenantId: null,
passwordHash: null,
verifyToken: null,
verifyTokenCreatedAt: null,
totpSecretEncrypted: null,
totpPendingSecretEncrypted: null,
totpEnabledAt: null,
totpPendingCreatedAt: null,
mfaRecoveryCodeHashes: Prisma.DbNull,
mfaRecoveryCodesGeneratedAt: null,
mfaRecoveryCodeLastUsedAt: null,
},
});
});
return { ...tenant, previousMemberIds };
}
export async function seedDemoProIdentity(db: DbClient, tenant: TenantRow, graph: DemoProSeedGraph, password: string): Promise<void> {
await db.tenant.update({ where: { id: tenant.id }, data: { name: STAGING_COMPANY_NAME } });
await syncDefaultRolesAndPermissions({ client: db });
await seedShkTemplateRolesForTenant(db, tenant.id);
const passwordHash = await hashDemoProUserPassword(password);
const ownerId = graph.employees[0]!.id;
await db.$transaction(
async (tx) => {
for (const employee of graph.employees) {
const user = await tx.user.upsert({
where: { email: employee.email },
create: {
id: employee.id,
email: employee.email,
passwordHash,
firstName: employee.firstName,
lastName: employee.lastName,
phone: employee.phone,
defaultTenantId: tenant.id,
locale: employee.locale,
active: true,
},
update: {
passwordHash,
firstName: employee.firstName,
lastName: employee.lastName,
phone: employee.phone,
defaultTenantId: tenant.id,
locale: employee.locale,
active: true,
},
select: { id: true },
});
if (user.id !== employee.id) {
throw new Error("demo-pro seed: a generated employee e-mail is already owned by a non-seed user");
}
}
await tx.userSession.deleteMany({ where: { userId: { in: graph.employees.map(({ id: employeeId }) => employeeId) } } });
const departmentNames = [...new Set(graph.employees.flatMap(({ groupNames }) => groupNames).filter((name) => !name.startsWith("Montageteam ")))];
const groups = [
...departmentNames.map((name, index) => ({
id: `${graph.namespace}-group-${slug(name)}`,
tenantId: tenant.id,
createdByUserId: ownerId,
name,
description: `Abteilung ${name} im Rhein-Main-Betrieb.`,
color: 2_579_098 + index * 137_531,
})),
...graph.crews.map((crew, index) => ({
id: crew.id,
tenantId: tenant.id,
createdByUserId: ownerId,
name: crew.name,
description: `Feste Montagekolonne ${String(index + 1).padStart(2, "0")} mit ${crew.memberEmployeeIds.length} Mitarbeitenden für Baustelleneinsätze.`,
color: 6_638_247 + index * 83_111,
})),
];
await tx.group.createMany({ data: groups });
const groupIdByName = new Map(groups.map(({ id, name }) => [name, id]));
await tx.tenantUser.createMany({
data: graph.employees.map((employee, index) => ({
id: `${graph.namespace}-tenant-user-${String(index + 1).padStart(3, "0")}`,
tenantId: tenant.id,
userId: employee.id,
type: index === 0 ? TENANT_USER_TYPE.OWNER : TENANT_USER_TYPE.MEMBER,
joined: index === 0 ? TENANT_USER_JOINED_CREATOR : TENANT_USER_JOINED_MEMBER,
status: TENANT_USER_STATUS_ACTIVE,
seatType: employee.seatCategory,
})),
});
const roles = await tx.role.findMany({
where: { tenantId: tenant.id, name: { in: [...new Set(graph.employees.map(({ role }) => role))] } },
select: { id: true, name: true },
});
const roleIdByName = new Map(roles.map(({ id, name }) => [name, id]));
await tx.userRole.createMany({
data: graph.employees.map((employee, index) => ({
id: `${graph.namespace}-user-role-${String(index + 1).padStart(3, "0")}`,
userId: employee.id,
roleId: roleIdByName.get(employee.role)!,
tenantId: tenant.id,
})),
});
const memberships = graph.employees.flatMap((employee) =>
employee.groupNames.map((name) => ({
id: `${graph.namespace}-group-user-${slug(name)}-${employee.id}`,
groupId: groupIdByName.get(name)!,
userId: employee.id,
}))
);
for (const batch of chunks(memberships)) await tx.groupUser.createMany({ data: batch });
},
{ maxWait: 15_000, timeout: 180_000 }
);
}
export async function hashDemoProUserPassword(password: string): Promise<string> {
return hashCurrentPassword(password);
}
function contactSearchProjection(input: {
tenantId: string;
id: string;
kind: "contact" | "company";
firstName?: string;
lastName?: string;
company?: string;
email: string;
status: string;
ownerId: string;
tags: string[];
createdAt: Date;
}): SearchProjection {
const title = input.kind === "company" ? input.company! : `${input.firstName} ${input.lastName}`;
return searchProjection({
schema: crmContactsIndex,
tenantId: input.tenantId,
entityType: input.kind,
entityId: input.id,
title,
subtitle: input.email,
bodyText: [title, input.email, ...input.tags].join(" "),
routePath: `/app/${input.tenantId}/${input.kind === "company" ? "contacts/companies" : "contacts"}/${input.id}`,
permissionSubject: "crm.read_own",
document: {
id: input.id,
tenantId: input.tenantId,
entityType: input.kind,
firstName: input.firstName ?? "",
lastName: input.lastName ?? "",
email: input.email,
company: input.company ?? "",
jobTitle: "",
tags: input.tags,
status: input.status,
ownerId: input.ownerId,
createdAt: input.createdAt.toISOString(),
updatedAt: input.createdAt.toISOString(),
lastInteractionAt: null,
},
});
}
export async function seedDemoProBusinessCore(db: DbClient, tenant: TenantRow, graph: DemoProSeedGraph): Promise<void> {
const ownerId = graph.employees[0]!.id;
const { rows: rawAddressRows, idByKey: addressIdByKey } = buildAddresses(graph);
const addressRows = rawAddressRows.map((row) => ({ ...row, tenantId: tenant.id }));
const tags = [
...new Set([
...graph.organizations.flatMap(({ tags }) => tags),
...graph.persons.flatMap(({ tags }) => tags),
...graph.projects.flatMap(({ tags }) => tags),
]),
];
const tagRows = tags.map((name, index) => ({
id: `${graph.namespace}-tag-${String(index + 1).padStart(3, "0")}`,
tenantId: tenant.id,
name,
farbe: ["#0ea5e9", "#22c55e", "#8b5cf6", "#f59e0b", "#ef4444", "#64748b"][index % 6]!,
}));
const tagIdByName = new Map(tagRows.map(({ id, name }) => [name, id]));
await withTenantTransaction(db, tenant.id, async (tx) => {
for (const batch of chunks(addressRows)) await tx.adresse.createMany({ data: batch });
await tx.tag.createMany({ data: tagRows });
const customFieldRows = [
{
id: `${graph.namespace}-field-kontakt-anrede`,
tenantId: tenant.id,
entityType: "kontakt" as const,
key: "anrede",
label: "Anrede",
datentyp: "text" as const,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
},
{
id: `${graph.namespace}-field-kontakt-branche`,
tenantId: tenant.id,
entityType: "kontakt" as const,
key: "branche",
label: "Branche",
datentyp: "text" as const,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
},
{
id: `${graph.namespace}-field-kontakt-segment`,
tenantId: tenant.id,
entityType: "kontakt" as const,
key: "kundensegment",
label: "Kundensegment",
datentyp: "select" as const,
optionen: { select: ["Privat", "B2B", "Wohnungswirtschaft", "Öffentlich"] },
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
},
{
id: `${graph.namespace}-field-projekt-gebaeude`,
tenantId: tenant.id,
entityType: "projekt" as const,
key: "gebaeudetyp",
label: "Gebäudetyp",
datentyp: "select" as const,
optionen: { select: ["Einfamilienhaus", "Mehrfamilienhaus", "Gewerbe", "Öffentlich"] },
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
},
];
await tx.customFieldDefinition.createMany({ data: customFieldRows });
const contactRows: Prisma.KontaktCreateManyInput[] = [];
const emailRows: Prisma.KontaktEmailCreateManyInput[] = [];
const phoneRows: Prisma.KontaktTelefonCreateManyInput[] = [];
const tagAssignments: Prisma.TagAssignmentCreateManyInput[] = [];
const customValues: Prisma.CustomFieldValueCreateManyInput[] = [];
const contactSearch: SearchProjection[] = [];
for (const [index, organization] of graph.organizations.entries()) {
const createdAt = new Date(new Date(graph.referenceDate).getTime() - ((index * 11) % 620) * 86_400_000);
contactRows.push({
id: organization.id,
tenantId: tenant.id,
createdByUserId: ownerId,
kontaktTyp: "organisation",
kontaktStatus: organization.kontaktStatus,
orgName: organization.orgName,
orgMerkmal: organization.orgMerkmal,
primaerEmail: organization.email,
primaerTelefon: organization.phone,
adresseId: addressIdByKey.get(addressKey(organization.address))!,
zustaendigerUserId: organization.ownerEmployeeId,
language: organization.language,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
createdAt,
});
emailRows.push({
id: `${organization.id}-email-primary`,
tenantId: tenant.id,
kontaktId: organization.id,
email: organization.email,
primaer: true,
createdAt,
});
organization.additionalEmails.forEach((channel, channelIndex) =>
emailRows.push({
id: `${organization.id}-email-${channelIndex + 1}`,
tenantId: tenant.id,
kontaktId: organization.id,
email: channel.value,
primaer: false,
createdAt,
})
);
phoneRows.push({
id: `${organization.id}-phone-primary`,
tenantId: tenant.id,
kontaktId: organization.id,
telefon: organization.phone,
primaer: true,
createdAt,
});
organization.additionalPhones.forEach((channel, channelIndex) =>
phoneRows.push({
id: `${organization.id}-phone-${channelIndex + 1}`,
tenantId: tenant.id,
kontaktId: organization.id,
telefon: channel.value,
primaer: false,
createdAt,
})
);
organization.tags.forEach((name) =>
tagAssignments.push({
id: `${organization.id}-tag-${slug(name)}`,
tenantId: tenant.id,
tagId: tagIdByName.get(name)!,
entityType: "kontakt",
entityId: organization.id,
createdAt,
})
);
customValues.push(
{
id: `${organization.id}-field-branche`,
tenantId: tenant.id,
definitionId: `${graph.namespace}-field-kontakt-branche`,
entityType: "kontakt",
entityId: organization.id,
wert: organization.branche,
createdAt,
},
{
id: `${organization.id}-field-segment`,
tenantId: tenant.id,
definitionId: `${graph.namespace}-field-kontakt-segment`,
entityType: "kontakt",
entityId: organization.id,
wert:
organization.orgMerkmal === "oeffentliche_einrichtung" ? "Öffentlich" : organization.tags.includes("Rahmenkunde") ? "Wohnungswirtschaft" : "B2B",
createdAt,
}
);
contactSearch.push(
contactSearchProjection({
tenantId: tenant.id,
id: organization.id,
kind: "company",
company: organization.orgName,
email: organization.email,
status: organization.kontaktStatus,
ownerId: organization.ownerEmployeeId,
tags: organization.tags,
createdAt,
})
);
}
for (const [index, person] of graph.persons.entries()) {
const createdAt = new Date(new Date(graph.referenceDate).getTime() - ((index * 7) % 710) * 86_400_000);
contactRows.push({
id: person.id,
tenantId: tenant.id,
createdByUserId: ownerId,
kontaktTyp: "privatperson",
kontaktStatus: person.kontaktStatus,
vorname: person.firstName,
nachname: person.lastName,
primaerEmail: person.email,
primaerTelefon: person.phone,
adresseId: addressIdByKey.get(addressKey(person.address))!,
zustaendigerUserId: person.ownerEmployeeId,
language: person.language,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
createdAt,
});
emailRows.push({ id: `${person.id}-email-primary`, tenantId: tenant.id, kontaktId: person.id, email: person.email, primaer: true, createdAt });
person.additionalEmails.forEach((channel, channelIndex) =>
emailRows.push({
id: `${person.id}-email-${channelIndex + 1}`,
tenantId: tenant.id,
kontaktId: person.id,
email: channel.value,
primaer: false,
createdAt,
})
);
phoneRows.push({ id: `${person.id}-phone-primary`, tenantId: tenant.id, kontaktId: person.id, telefon: person.phone, primaer: true, createdAt });
person.additionalPhones.forEach((channel, channelIndex) =>
phoneRows.push({
id: `${person.id}-phone-${channelIndex + 1}`,
tenantId: tenant.id,
kontaktId: person.id,
telefon: channel.value,
primaer: false,
createdAt,
})
);
person.tags.forEach((name) =>
tagAssignments.push({
id: `${person.id}-tag-${slug(name)}`,
tenantId: tenant.id,
tagId: tagIdByName.get(name)!,
entityType: "kontakt",
entityId: person.id,
createdAt,
})
);
customValues.push(
{
id: `${person.id}-field-anrede`,
tenantId: tenant.id,
definitionId: `${graph.namespace}-field-kontakt-anrede`,
entityType: "kontakt",
entityId: person.id,
wert: person.salutation,
createdAt,
},
{
id: `${person.id}-field-segment`,
tenantId: tenant.id,
definitionId: `${graph.namespace}-field-kontakt-segment`,
entityType: "kontakt",
entityId: person.id,
wert: person.tags.includes("Privatkunde") ? "Privat" : "B2B",
createdAt,
}
);
contactSearch.push(
contactSearchProjection({
tenantId: tenant.id,
id: person.id,
kind: "contact",
firstName: person.firstName,
lastName: person.lastName,
email: person.email,
status: person.kontaktStatus,
ownerId: person.ownerEmployeeId,
tags: person.tags,
createdAt,
})
);
}
for (const batch of chunks(contactRows)) await tx.kontakt.createMany({ data: batch });
for (const batch of chunks(emailRows)) await tx.kontaktEmail.createMany({ data: batch });
for (const batch of chunks(phoneRows)) await tx.kontaktTelefon.createMany({ data: batch });
for (const batch of chunks(
graph.contactRelations.map((relation) => ({
id: relation.id,
tenantId: tenant.id,
vonKontaktId: relation.organizationId,
zuKontaktId: relation.personId,
rolle: relation.role,
kontext: relation.context,
}))
))
await tx.kontaktRelation.createMany({ data: batch });
for (const batch of chunks(tagAssignments)) await tx.tagAssignment.createMany({ data: batch });
for (const batch of chunks(customValues)) await tx.customFieldValue.createMany({ data: batch });
await writeSearchProjections(tx, contactSearch);
});
await withTenantTransaction(db, tenant.id, async (tx) => {
const pipelines = [
{
id: pipelineId(graph, "sales"),
tenantId: tenant.id,
createdByUserId: ownerId,
name: "Vertrieb Rhein-Main",
typHint: "sales" as const,
position: 0,
isDefault: true,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
},
{
id: pipelineId(graph, "auftrag"),
tenantId: tenant.id,
createdByUserId: ownerId,
name: "Auftragsabwicklung Rhein-Main",
typHint: "auftrag" as const,
position: 10,
isDefault: false,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
},
];
await tx.pipeline.createMany({ data: pipelines });
const salesStages = [
["neu", "Neu", "offen", 15, "#64748b"],
["qualifiziert", "Qualifiziert", "offen", 40, "#0ea5e9"],
["angebot", "Angebot", "offen", 80, "#8b5cf6"],
["gewonnen", "Gewonnen", "gewonnen", 100, "#22c55e"],
["verloren", "Verloren", "verloren", 0, "#ef4444"],
] as const;
const orderStages = [
["planung", "Planung", "offen", 100, "#64748b"],
["montage", "Montage", "offen", 100, "#0ea5e9"],
["abnahme", "Abnahme", "offen", 100, "#f59e0b"],
["abgeschlossen", "Abgeschlossen", "gewonnen", 100, "#22c55e"],
["storniert", "Storniert", "verloren", 0, "#ef4444"],
] as const;
await tx.pipelineStage.createMany({
data: [
...salesStages.map((stage, position) => ({
id: stageId(graph, "sales", stage[0]),
tenantId: tenant.id,
pipelineId: pipelineId(graph, "sales"),
name: stage[1],
stageKind: stage[2],
winProbability: stage[3],
color: stage[4],
position,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
})),
...orderStages.map((stage, position) => ({
id: stageId(graph, "auftrag", stage[0]),
tenantId: tenant.id,
pipelineId: pipelineId(graph, "auftrag"),
name: stage[1],
stageKind: stage[2],
winProbability: stage[3],
color: stage[4],
position,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
})),
],
});
await tx.lostReason.createMany({
data: STAGING_LOST_REASONS.map((name, position) => ({
id: `${graph.namespace}-lost-${slug(name)}`,
tenantId: tenant.id,
name,
position: (position + 1) * 10,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
})),
});
await tx.pipelineAccess.createMany({
data: graph.employees
.filter(({ seatCategory }) => seatCategory === "office")
.flatMap((employee) =>
pipelines.map((pipeline) => ({
id: `${pipeline.id}-access-${employee.id}`,
tenantId: tenant.id,
pipelineId: pipeline.id,
principalType: "user" as const,
principalId: employee.id,
kannSehen: true,
kannBearbeiten: true,
}))
),
});
const leadSources = [
["website", "Website Kontaktformular"],
["email", "E-Mail Eingang"],
["aircall", "Telefonzentrale"],
["manual", "Manuelle Erfassung"],
["partner", "Partnerportal"],
] as const;
await tx.leadSource.createMany({
data: leadSources.map(([key, name], index) => ({
id: `${graph.namespace}-lead-source-${key}`,
tenantId: tenant.id,
type: index < 3 ? ("system" as const) : ("custom" as const),
key,
name,
displayLabel: name,
defaultZustaendigerUserId: graph.employees.find(({ role }) => role === "Vertriebsleiter")!.id,
defaultTags: ["Rhein-Main", "Neuanfrage"],
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
})),
});
await tx.leadAntiSpamConfig.create({
data: {
id: `${graph.namespace}-lead-antispam`,
tenantId: tenant.id,
perSourcePerHourLimit: 100,
perIpPerHourLimit: 30,
anomalySpikeFactor: 10,
mode: "quarantine",
disposableDomainEnabled: true,
honeypotEnabled: true,
fastSubmitEnabled: true,
lostAutoDeleteDays: 0,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
},
});
const leadSourceByKind = new Map([
["form", "website"],
["email", "email"],
["aircall", "aircall"],
["manual", "manual"],
["custom", "partner"],
]);
const leadRows = graph.leads.map((lead) => ({
id: lead.id,
tenantId: tenant.id,
createdByUserId: lead.ownerEmployeeId,
zustaendigerUserId: lead.ownerEmployeeId,
quelle: lead.source,
quelleDetail: { campaign: "rheinmain-shk-v1", outcome: lead.outcome },
sourceId: `${graph.namespace}-lead-source-${leadSourceByKind.get(lead.source)!}`,
sourceOrigin: lead.sourceOrigin,
status: lead.status,
weiterverarbeitungFreigabe: lead.furtherProcessingConsent,
vorname: lead.firstName,
nachname: lead.lastName,
email: lead.email,
telefon: lead.phone,
message: lead.message,
rohdaten: { processedBy: "inbound-routing", disqualifiedReason: lead.disqualifiedReason },
spamScore: lead.spamScore,
spamReason: lead.spamReason,
isQuarantined: lead.isQuarantined,
konvertiertProjektId: lead.convertedProjectId,
verknuepfterKontaktId: lead.linkedContactId,
convertedAt: iso(lead.convertedAt),
lostAt: iso(lead.lostAt),
spamMarkedAt: lead.outcome === "spam" ? new Date(lead.createdAt) : null,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
createdAt: new Date(lead.createdAt),
}));
for (const batch of chunks(leadRows)) await tx.inboxLead.createMany({ data: batch });
const leadSearch = graph.leads.map((lead) =>
searchProjection({
schema: crmContactsIndex,
tenantId: tenant.id,
entityType: "lead",
entityId: lead.id,
title: `${lead.firstName} ${lead.lastName}`,
subtitle: lead.email,
bodyText: `${lead.firstName} ${lead.lastName} ${lead.email} ${lead.phone} ${lead.message}`,
routePath: `/app/${tenant.id}/leads/${lead.id}`,
permissionSubject: "leads.read_own",
document: {
id: lead.id,
tenantId: tenant.id,
entityType: "lead",
firstName: lead.firstName,
lastName: lead.lastName,
email: lead.email,
company: lead.sourceOrigin,
jobTitle: "",
tags: ["Rhein-Main", lead.outcome],
status: lead.status,
ownerId: lead.ownerEmployeeId,
createdAt: lead.createdAt,
updatedAt: lead.createdAt,
lastInteractionAt: lead.convertedAt,
},
})
);
await writeSearchProjections(tx, leadSearch);
const projectRows = graph.projects.map((project) => ({
id: project.id,
tenantId: tenant.id,
createdAt: new Date(project.createdAt),
createdByUserId: ownerId,
projektNummer: project.projectNumber,
projektname: project.projectName,
beschreibung: project.description,
projekttyp: project.projectType,
quelle: project.source,
prioritaet: project.priority,
pipelineId: pipelineId(graph, project.pipeline),
stageId: stageId(graph, project.pipeline, project.stage),
potenziellerAuftragswert: project.potentialOrderValue,
auftragsvolumen: project.orderVolume,
startDatum: iso(project.startDate),
deadline: iso(project.deadline),
zustaendigePersonId: project.responsibleEmployeeId,
projektleiterId: project.projectManagerEmployeeId,
projektAdresseId: addressIdByKey.get(addressKey(project.projectAddress))!,
rechnungsAdresseId: addressIdByKey.get(addressKey(project.invoiceAddress))!,
bauherrAdresseId: addressIdByKey.get(addressKey(project.clientAddress))!,
konvertiertAusLeadId: project.convertedFromLeadId,
lostReasonId: project.lostReason ? `${graph.namespace}-lost-${slug(project.lostReason)}` : null,
wonAt: iso(project.wonAt),
lostAt: iso(project.lostAt),
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
}));
for (const batch of chunks(projectRows, 150)) await tx.projekt.createMany({ data: batch });
const projectTags = graph.projects.flatMap((project) =>
project.tags.map((name) => ({
id: `${project.id}-tag-${slug(name)}`,
tenantId: tenant.id,
tagId: tagIdByName.get(name)!,
entityType: "projekt",
entityId: project.id,
createdAt: new Date(project.createdAt),
}))
);
for (const batch of chunks(projectTags)) await tx.tagAssignment.createMany({ data: batch });
const projectFieldValues = graph.projects.map((project) => ({
id: `${project.id}-field-gebaeude`,
tenantId: tenant.id,
definitionId: `${graph.namespace}-field-projekt-gebaeude`,
entityType: "projekt" as const,
entityId: project.id,
wert: project.projectType === "objekt_neubau" ? "Mehrfamilienhaus" : project.tags.includes("B2B") ? "Gewerbe" : "Einfamilienhaus",
createdAt: new Date(project.createdAt),
}));
for (const batch of chunks(projectFieldValues)) await tx.customFieldValue.createMany({ data: batch });
const projectSearch = graph.projects.map((project) =>
searchProjection({
schema: projectsIndex,
tenantId: tenant.id,
entityType: "project",
entityId: project.id,
title: project.projectName,
subtitle: project.projectNumber,
bodyText: `${project.projectName} ${project.projectNumber} ${project.description} ${project.projectType} ${project.source}`,
routePath: `/app/${tenant.id}/projects/${project.id}`,
permissionSubject: "projects.read_own",
document: {
id: project.id,
tenantId: tenant.id,
projectNumber: project.projectNumber,
name: project.projectName,
description: project.description,
type: project.projectType,
source: project.source,
pipelineId: pipelineId(graph, project.pipeline),
stageId: stageId(graph, project.pipeline, project.stage),
ownerId: project.responsibleEmployeeId,
projectLeadId: project.projectManagerEmployeeId,
archived: false,
createdAt: project.createdAt,
updatedAt: project.createdAt,
deadline: project.deadline,
potentialValue: project.potentialOrderValue ? Number(project.potentialOrderValue) : null,
orderVolume: project.orderVolume ? Number(project.orderVolume) : null,
},
})
);
await writeSearchProjections(tx, projectSearch);
});
}
export async function seedDemoProOperations(db: DbClient, tenant: TenantRow, graph: DemoProSeedGraph, operational: DemoProOperationalGraph): Promise<void> {
const ownerId = graph.employees[0]!.id;
const projectById = new Map(graph.projects.map((project) => [project.id, project]));
const fileById = new Map(operational.files.map((file) => [file.id, file]));
await withTenantTransaction(db, tenant.id, async (tx) => {
for (const batch of chunks(
operational.projectContacts.map((link) => ({
id: link.id,
tenantId: tenant.id,
projektId: link.projectId,
kontaktId: link.contactId,
rolle: link.role,
primaer: link.primary,
}))
))
await tx.projektKontakt.createMany({ data: batch });
for (const batch of chunks(
operational.projectMembers.map((member) => ({
id: member.id,
tenantId: tenant.id,
projektId: member.projectId,
userId: member.userId,
groupId: member.groupId,
rolle: member.role,
createdByUserId: ownerId,
}))
))
await tx.projektMitglied.createMany({ data: batch });
for (const batch of chunks(
operational.projectAppointments.map((appointment) => ({
id: appointment.id,
tenantId: tenant.id,
projektId: appointment.projectId,
createdByUserId: ownerId,
assignedToUserId: appointment.userId,
titel: appointment.title,
beschreibung: appointment.description,
startAt: new Date(appointment.startsAt),
endAt: new Date(appointment.endsAt),
status: appointment.status,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
}))
))
await tx.projektTermin.createMany({ data: batch });
const taskRows = operational.tasks.map((task) => ({
id: task.id,
tenantId: tenant.id,
createdByUserId: ownerId,
title: task.title,
description: task.description,
status: task.status,
priority: task.priority,
deadline: new Date(task.deadline),
assignedToUserId: task.assignedToUserId,
assignedToGroupId: task.assignedToGroupId,
linkedEntityType: "project" as const,
linkedEntityId: task.projectId,
source: "manual" as const,
sourceRef: `${graph.namespace}:${task.projectId}`,
completedAt: iso(task.completedAt),
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
createdAt: new Date(task.createdAt),
updatedAt: new Date(task.updatedAt),
}));
for (const batch of chunks(taskRows, 150)) await tx.task.createMany({ data: batch });
for (const batch of chunks(
operational.taskWatchers.map((watcher) => ({ id: watcher.id, tenantId: tenant.id, taskId: watcher.taskId, userId: watcher.userId }))
))
await tx.taskWatcher.createMany({ data: batch });
for (const batch of chunks(
operational.activities.map((activity) => ({
id: activity.id,
tenantId: tenant.id,
createdAt: new Date(activity.createdAt),
actorUserId: activity.actorUserId,
actorKind: "user" as const,
activityType: activity.activityType,
subjectType: "projekt",
subjectId: activity.projectId,
relatedEntityRefs: [{ entityType: "projekt", entityId: activity.projectId }] as Prisma.InputJsonValue,
metadata: activity.metadata as Prisma.InputJsonValue,
summaryText: activity.summaryText,
}))
))
await tx.activity.createMany({ data: batch });
for (const batch of chunks(
operational.scheduleEntries.map((entry) => ({
id: entry.id,
tenantId: tenant.id,
projektId: entry.projectId,
assignedUserId: entry.assignedUserId,
createdByUserId: ownerId,
title: entry.title,
startAt: new Date(entry.startAt),
endAt: new Date(entry.endAt),
status: entry.status,
calendarEventId: entry.calendarEventId,
syncStatus: entry.syncStatus,
clientMutationId: `${graph.namespace}:${entry.id}`,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
}))
))
await tx.scheduleEntry.createMany({ data: batch });
await tx.scheduleConflict.createMany({
data: operational.scheduleConflicts.map((conflict, index) => ({
id: conflict.id,
tenantId: tenant.id,
entryId: conflict.entryId,
type: "double_booking" as const,
severity: index % 4 === 0 ? ("blocking" as const) : ("warning" as const),
messageKey: "projects.error.scheduleDoubleBooking",
sourceKind: "schedule_entry",
sourceId: conflict.sourceEntryId,
resolvedAt: iso(conflict.resolvedAt),
overriddenAt: index % 5 === 0 ? iso(conflict.resolvedAt) : null,
})),
});
for (const batch of chunks(
operational.calendarEvents.map((event) => ({
id: event.id,
tenantId: tenant.id,
userId: event.userId,
source: "hivegrid_internal" as const,
title: event.title,
description: event.description,
location: event.location,
startsAt: new Date(event.startsAt),
endsAt: new Date(event.endsAt),
timeZone: "Europe/Berlin",
attendees: [],
isBusyForAvailability: true,
internalEntityLink: { entityType: "projekt", entityId: event.projectId },
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
}))
))
await tx.calendarEvent.createMany({ data: batch });
await tx.bookingPage.createMany({
data: operational.bookingPages.map((page) => ({
id: page.id,
tenantId: tenant.id,
createdByUserId: ownerId,
slug: page.slug,
title: page.title,
description: page.description,
durationMinutes: page.durationMinutes,
bufferBeforeMinutes: 10,
bufferAfterMinutes: 15,
slotIncrementMinutes: 15,
minNoticeHours: page.durationMinutes === 30 ? 1 : 4,
maxAdvanceDays: 45,
assignmentStrategy: "first_available" as const,
confirmationEmailSubject: "Ihr Termin bei RheinMain Gebäudetechnik ist bestätigt",
confirmationEmailBody: "Vielen Dank für Ihre Buchung. Wir melden uns vor dem Termin mit allen Details.",
internalEventTitleTemplate: "Kundentermin: {{customerName}}",
isPublished: false,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
})),
});
await tx.bookingPageProvider.createMany({
data: operational.bookingProviders.map((provider) => ({ ...provider, tenantId: tenant.id, isActive: true })),
});
await tx.availabilityRule.createMany({
data: operational.availabilityRules.map((rule) => ({ ...rule, tenantId: tenant.id, timezone: "Europe/Berlin" })),
});
await tx.availabilityException.createMany({
data: operational.availabilityExceptions.map((exception) => ({
...exception,
tenantId: tenant.id,
date: new Date(exception.date),
timezone: "Europe/Berlin",
})),
});
await tx.appointment.createMany({
data: operational.bookingAppointments.map((appointment) => ({
id: appointment.id,
tenantId: tenant.id,
bookingPageId: appointment.bookingPageId,
providerUserId: appointment.providerUserId,
startAt: new Date(appointment.startsAt),
endAt: new Date(appointment.endsAt),
timezone: "Europe/Berlin",
status: appointment.status,
kontaktId: appointment.contactId,
intakeAnswers: {
customerName: appointment.customerName,
email: appointment.customerEmail,
phone: appointment.customerPhone,
request: appointment.request,
projectId: appointment.projectId,
} as Prisma.InputJsonValue,
internalCalendarEventId: appointment.internalCalendarEventId,
cancelledAt: appointment.status === "cancelled" ? new Date(appointment.startsAt) : null,
cancelledByKind: appointment.status === "cancelled" ? ("customer" as const) : null,
cancellationReason: appointment.status === "cancelled" ? "Termin wurde rechtzeitig auf Kundenwunsch abgesagt." : null,
notesInternal: "Intern abgestimmter Kundentermin.",
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
})),
});
for (const batch of chunks(
operational.invoices.map((invoice) => ({
id: invoice.id,
tenantId: tenant.id,
projektId: invoice.projectId,
createdByUserId: ownerId,
rechnungsnummer: invoice.invoiceNumber,
betrag: invoice.amount,
status: invoice.status,
datum: new Date(invoice.date),
faelligAm: new Date(invoice.dueAt),
dateiId: invoice.fileId,
quelle: "manuell" as const,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
createdAt: new Date(invoice.createdAt),
updatedAt: new Date(invoice.updatedAt),
}))
))
await tx.rechnung.createMany({ data: batch });
for (const batch of chunks(
operational.checklists.map((checklist) => ({
id: checklist.id,
tenantId: tenant.id,
projektId: checklist.projectId,
createdByUserId: ownerId,
titel: checklist.title,
typ: checklist.type,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
}))
))
await tx.projektChecklist.createMany({ data: batch });
for (const batch of chunks(
operational.checklistItems.map((item) => ({
id: item.id,
tenantId: tenant.id,
checklistId: item.checklistId,
titel: item.title,
status: item.status,
maengelKlasse: item.defectClass,
assignedToUserId: item.assignedToUserId,
dueAt: new Date(item.dueAt),
position: item.position,
// Generated staging assets are PDFs, so no row is misrepresented as a checklist photo.
fotoFileId: item.photoFileId,
}))
))
await tx.projektChecklistItem.createMany({ data: batch });
const fileRows = operational.files.map((file) => ({
id: file.id,
tenantId: tenant.id,
createdByUserId: ownerId,
displayName: file.displayName,
mimeType: file.mimeType,
sizeBytes: file.sizeBytes,
storageKey: seedFileStorageKey(tenant.id, file.id),
checksumSha256: file.checksumSha256,
previewStatus: "not_supported" as const,
// These PDFs are generated by this trusted server process, never supplied
// by a user or remote source, so the untrusted-upload AV gate is not applicable.
scanStatus: "clean" as const,
assignedToUserId: file.assignedToUserId,
tags: file.tags,
notes: file.notes,
source: "manual_upload" as const,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
createdAt: new Date(file.createdAt),
updatedAt: new Date(file.updatedAt),
}));
for (const batch of chunks(fileRows, 150)) await tx.file.createMany({ data: batch });
for (const batch of chunks(
operational.files.map((file) => ({
id: `${file.id}-link`,
tenantId: tenant.id,
createdByUserId: ownerId,
fileId: file.id,
entityType: "project" as const,
entityId: file.projectId,
linkRole: file.linkRole,
}))
))
await tx.fileEntityLink.createMany({ data: batch });
await tx.abnahmeProtokoll.createMany({
data: operational.acceptances.map((acceptance) => ({
id: acceptance.id,
tenantId: tenant.id,
projektId: acceptance.projectId,
maengelChecklistId: acceptance.checklistId,
createdByUserId: acceptance.creatorUserId,
datum: new Date(acceptance.date),
acceptanceKind: acceptance.kind,
anwesende: acceptance.attendees,
druckprobe: { status: "bestanden", pruefdruckBar: 6, dauerMinuten: 30 },
funktionspruefung: { status: "bestanden", regelung: true, sicherheitseinrichtungen: true },
zaehlerstaende: { waermeKwh: 0, stromKwh: 0 },
// No attachment shape is defined for seeded supporting documents.
// The generated protocol PDF is linked through the typed pdfFileId instead.
dokumente: [],
bemerkungen: acceptance.remarks,
pdfFileId: acceptance.documentFileId,
customerEmail: acceptance.customerEmail,
clientMutationId: `${graph.namespace}:${acceptance.id}`,
gesperrtAt: new Date(acceptance.updatedAt),
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
createdAt: new Date(acceptance.createdAt),
updatedAt: new Date(acceptance.updatedAt),
})),
});
await tx.abnahmeSignatur.createMany({
data: operational.acceptanceSignatures.map((signature) => ({
id: signature.id,
tenantId: tenant.id,
protokollId: signature.acceptanceId,
signerName: signature.signerName,
signerRole: signature.signerRole,
signedAt: new Date(signature.signedAt),
createdAt: new Date(signature.signedAt),
})),
});
await tx.signatureProviderConnection.create({
data: {
id: `${graph.namespace}-signature-provider`,
tenantId: tenant.id,
provider: "ses_selfhosted",
label: "HiveGrid SES Signatur",
isDefault: true,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
},
});
await tx.projektSignatureDocument.createMany({
data: operational.signatureDocuments.map((document) => ({
id: document.id,
tenantId: tenant.id,
projektId: document.projectId,
providerConnectionId: `${graph.namespace}-signature-provider`,
provider: "ses_selfhosted" as const,
sourcePdfFileId: document.sourceFileId,
status: document.status,
routingMode: "parallel" as const,
title: document.title,
subject: `Elektronische Freigabe ${projectById.get(document.projectId)!.projectNumber}`,
message: "Bitte prüfen und bestätigen Sie das beigefügte Angebot.",
documentHashSha256: fileById.get(document.sourceFileId)!.checksumSha256,
sentAt: new Date(document.sentAt),
completedAt: iso(document.completedAt),
expiresAt: new Date(new Date(document.sentAt).getTime() + 30 * 86_400_000),
createdByUserId: document.creatorUserId,
isDemoData: STAGING_BUSINESS_DATA_IS_DEMO,
createdAt: new Date(document.createdAt),
updatedAt: new Date(document.updatedAt),
})),
});
await tx.projektSignatureSigner.createMany({
data: operational.signatureSigners.map((signer, index) => ({
id: signer.id,
tenantId: tenant.id,
documentId: signer.documentId,
signerEmail: signer.email,
signerName: signer.name,
signerRole: "kunde" as const,
linkedKontaktId: signer.contactId,
status: signer.status,
signingOrder: 0,
viewedAt:
signer.status === "viewed" || signer.status === "signed"
? new Date(new Date(operational.signatureDocuments[index]!.sentAt).getTime() + 3_600_000)
: null,
signedAt: iso(signer.signedAt),
intentText: signer.status === "signed" ? "Ich bestätige das Angebot und beauftrage die beschriebenen Leistungen." : null,
createdAt: new Date(operational.signatureDocuments[index]!.createdAt),
updatedAt: new Date(operational.signatureDocuments[index]!.updatedAt),
})),
});
await tx.signatureEvent.createMany({
data: operational.signatureDocuments.flatMap((document, index) => {
const signer = operational.signatureSigners[index]!;
const events: Prisma.SignatureEventCreateManyInput[] = [
{
id: `${document.id}-event-sent`,
tenantId: tenant.id,
documentId: document.id,
signerId: signer.id,
eventType: "document_sent",
actorUserId: document.creatorUserId,
metadata: { importBatch: graph.namespace },
createdAt: new Date(document.sentAt),
},
];
if (document.status === "viewed" || document.status === "signed")
events.push({
id: `${document.id}-event-viewed`,
tenantId: tenant.id,
documentId: document.id,
signerId: signer.id,
eventType: "document_viewed",
metadata: { importBatch: graph.namespace },
createdAt: new Date(new Date(document.sentAt).getTime() + 3_600_000),
});
if (document.status === "signed")
events.push({
id: `${document.id}-event-signed`,
tenantId: tenant.id,
documentId: document.id,
signerId: signer.id,
eventType: "signer_signed",
metadata: { importBatch: graph.namespace },
createdAt: new Date(document.completedAt!),
});
return events;
}),
});
const taskSearch = operational.tasks.map((task) =>
searchProjection({
schema: tasksIndex,
tenantId: tenant.id,
entityType: "task",
entityId: task.id,
title: task.title,
subtitle: task.status,
bodyText: `${task.title} ${task.description}`,
routePath: `/app/${tenant.id}/tasks/${task.id}`,
permissionSubject: "tasks.read_own",
document: {
id: task.id,
tenantId: tenant.id,
title: task.title,
description: task.description,
status: task.status,
priority: task.priority,
createdByUserId: ownerId,
assignedToUserId: task.assignedToUserId ?? "",
assignedToGroupId: task.assignedToGroupId ?? "",
linkedEntityType: "project",
linkedEntityId: task.projectId,
archived: false,
createdAt: graph.referenceDate,
updatedAt: graph.referenceDate,
deadline: task.deadline,
},
})
);
const fileSearch = operational.files.map((file) =>
searchProjection({
schema: filesIndex,
tenantId: tenant.id,
entityType: "file",
entityId: file.id,
title: file.displayName,
subtitle: file.mimeType,
bodyText: `${file.displayName} ${file.notes} ${file.tags.join(" ")}`,
routePath: `/app/${tenant.id}/files/${file.id}`,
permissionSubject: "files.read_own",
document: {
id: file.id,
tenantId: tenant.id,
displayName: file.displayName,
notes: file.notes,
tags: file.tags,
mimeType: file.mimeType,
createdByUserId: ownerId,
assignedToUserId: file.assignedToUserId,
archived: false,
createdAt: graph.referenceDate,
updatedAt: graph.referenceDate,
sizeBytes: file.sizeBytes,
},
})
);
const userSearch = graph.employees.map((employee) =>
searchProjection({
schema: usersIndex,
tenantId: tenant.id,
entityType: "user",
entityId: employee.id,
title: `${employee.firstName} ${employee.lastName}`,
subtitle: employee.email,
bodyText: `${employee.firstName} ${employee.lastName} ${employee.email} ${employee.jobTitle}`,
routePath: `/app/${tenant.id}/settings/members`,
permissionSubject: "app.settings.members.view",
document: {
id: employee.id,
tenantId: tenant.id,
firstName: employee.firstName,
lastName: employee.lastName,
email: employee.email,
type: employee.id === graph.employees[0]!.id ? TENANT_USER_TYPE.OWNER : TENANT_USER_TYPE.MEMBER,
admin: false,
active: true,
createdAt: graph.referenceDate,
lastLoginAt: null,
},
})
);
await writeSearchProjections(tx, [...taskSearch, ...fileSearch, ...userSearch]);
});
}
export async function verifyDemoProTenant(db: DbClient, tenantSlug: string, options: DemoProVerificationOptions): Promise<SeedVerificationReport> {
const tenant = await findTenant(db, tenantSlug);
const demoFlaggedRecords = Promise.all(
TENANT_MODELS_WITH_DEMO_FLAG.map((model) => tenantDemoRowsDelegate(db, model).count({ where: { tenantId: tenant.id, isDemoData: true } }))
).then((counts) => counts.reduce((total, count) => total + count, 0));
const countQueries: Record<string, Promise<number>> = {
employees: db.tenantUser.count({ where: { tenantId: tenant.id, status: TENANT_USER_STATUS_ACTIVE } }),
officeEmployees: db.tenantUser.count({ where: { tenantId: tenant.id, status: TENANT_USER_STATUS_ACTIVE, seatType: "office" } }),
fieldEmployees: db.tenantUser.count({ where: { tenantId: tenant.id, status: TENANT_USER_STATUS_ACTIVE, seatType: "field" } }),
roleAssignments: db.userRole.count({ where: { tenantId: tenant.id } }),
groups: db.group.count({ where: { tenantId: tenant.id } }),
groupMemberships: db.groupUser.count({ where: { group: { tenantId: tenant.id } } }),
contacts: db.kontakt.count({ where: { tenantId: tenant.id } }),
demoFlaggedRecords,
organizationContacts: db.kontakt.count({ where: { tenantId: tenant.id, kontaktTyp: "organisation" } }),
personContacts: db.kontakt.count({ where: { tenantId: tenant.id, kontaktTyp: "privatperson" } }),
contactEmails: db.kontaktEmail.count({ where: { tenantId: tenant.id } }),
contactPhones: db.kontaktTelefon.count({ where: { tenantId: tenant.id } }),
contactRelations: db.kontaktRelation.count({ where: { tenantId: tenant.id } }),
projects: db.projekt.count({ where: { tenantId: tenant.id } }),
lostReasons: db.lostReason.count({ where: { tenantId: tenant.id } }),
leads: db.inboxLead.count({ where: { tenantId: tenant.id } }),
projectContacts: db.projektKontakt.count({ where: { tenantId: tenant.id } }),
projectMembers: db.projektMitglied.count({ where: { tenantId: tenant.id } }),
tasks: db.task.count({ where: { tenantId: tenant.id } }),
taskWatchers: db.taskWatcher.count({ where: { tenantId: tenant.id } }),
activities: db.activity.count({ where: { tenantId: tenant.id } }),
scheduleEntries: db.scheduleEntry.count({ where: { tenantId: tenant.id } }),
scheduleConflicts: db.scheduleConflict.count({ where: { tenantId: tenant.id } }),
calendarEvents: db.calendarEvent.count({ where: { tenantId: tenant.id, isDemoData: STAGING_BUSINESS_DATA_IS_DEMO } }),
projectAppointments: db.projektTermin.count({ where: { tenantId: tenant.id, isDemoData: STAGING_BUSINESS_DATA_IS_DEMO } }),
bookingPages: db.bookingPage.count({ where: { tenantId: tenant.id, isDemoData: STAGING_BUSINESS_DATA_IS_DEMO } }),
publishedBookingPages: db.bookingPage.count({
where: { tenantId: tenant.id, isDemoData: STAGING_BUSINESS_DATA_IS_DEMO, isPublished: true },
}),
bookingProviders: db.bookingPageProvider.count({ where: { tenantId: tenant.id } }),
availabilityRules: db.availabilityRule.count({ where: { tenantId: tenant.id } }),
availabilityExceptions: db.availabilityException.count({ where: { tenantId: tenant.id } }),
bookingAppointments: db.appointment.count({ where: { tenantId: tenant.id, isDemoData: STAGING_BUSINESS_DATA_IS_DEMO } }),
invoices: db.rechnung.count({ where: { tenantId: tenant.id } }),
checklists: db.projektChecklist.count({ where: { tenantId: tenant.id } }),
checklistItems: db.projektChecklistItem.count({ where: { tenantId: tenant.id } }),
files: db.file.count({ where: { tenantId: tenant.id } }),
pendingFiles: db.file.count({ where: { tenantId: tenant.id, scanStatus: "pending" } }),
cleanFiles: db.file.count({ where: { tenantId: tenant.id, scanStatus: "clean" } }),
acceptances: db.abnahmeProtokoll.count({ where: { tenantId: tenant.id } }),
acceptanceSignatures: db.abnahmeSignatur.count({ where: { tenantId: tenant.id } }),
signatureDocuments: db.projektSignatureDocument.count({ where: { tenantId: tenant.id } }),
signatureSigners: db.projektSignatureSigner.count({ where: { tenantId: tenant.id } }),
searchEntries: db.searchIndexEntry.count({ where: { tenantId: tenant.id, isArchived: false } }),
pendingSearchEvents: db.outboxEvent.count({
where: { tenantId: tenant.id, eventType: { startsWith: "search.index." }, status: { in: ["PENDING", "PROCESSING"] } },
}),
failedSearchEvents: db.outboxEvent.count({ where: { tenantId: tenant.id, eventType: { startsWith: "search.index." }, status: "FAILED" } }),
};
const countEntries = await Promise.all(Object.entries(countQueries).map(async ([key, query]) => [key, await query] as const));
const counts = Object.fromEntries(countEntries) as Record<string, number>;
const expected: Record<string, number> = {
employees: 50,
officeEmployees: 17,
fieldEmployees: 33,
roleAssignments: 50,
groups: 22,
groupMemberships: 83,
contacts: 783,
demoFlaggedRecords: 0,
organizationContacts: 247,
personContacts: 536,
contactEmails: 894,
contactPhones: 819,
contactRelations: 421,
projects: 537,
lostReasons: 9,
leads: 193,
...OPERATIONAL_COUNTS,
publishedBookingPages: 0,
pendingFiles: 0,
cleanFiles: OPERATIONAL_COUNTS.files,
searchEntries: 4164,
};
const errors = Object.entries(expected).flatMap(([key, value]) => (counts[key] === value ? [] : [`${key}: expected ${value}, received ${counts[key] ?? 0}`]));
if (tenant.name !== STAGING_COMPANY_NAME) errors.push(`tenantName: expected ${STAGING_COMPANY_NAME}, received ${tenant.name}`);
if (tenant.active !== options.expectTenantActive) {
errors.push(`tenantActive: expected ${options.expectTenantActive}, received ${tenant.active}`);
}
const [
tenantUsers,
seedUsers,
roleAssignments,
groups,
contacts,
projects,
projectContacts,
projectMembers,
tasks,
scheduleRows,
bookingPages,
calendarEvents,
appointments,
invoiceRows,
checklistItemRows,
files,
acceptanceRows,
signatureDocuments,
lostReasonRows,
templateRoles,
] = await Promise.all([
db.tenantUser.findMany({
where: { tenantId: tenant.id },
select: { userId: true, type: true, status: true, seatType: true },
}),
db.user.findMany({
where: { id: { in: options.graph.employees.map(({ id }) => id) } },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
phone: true,
locale: true,
active: true,
defaultTenantId: true,
passwordHash: true,
},
}),
db.userRole.findMany({
where: { tenantId: tenant.id },
select: { userId: true, role: { select: { name: true } } },
}),
db.group.findMany({ where: { tenantId: tenant.id }, select: { id: true } }),
db.kontakt.findMany({ where: { tenantId: tenant.id }, select: { id: true } }),
db.projekt.findMany({ where: { tenantId: tenant.id }, select: { id: true, stageId: true, pipelineId: true } }),
db.projektKontakt.findMany({ where: { tenantId: tenant.id }, select: { projektId: true, kontaktId: true } }),
db.projektMitglied.findMany({ where: { tenantId: tenant.id }, select: { projektId: true, userId: true, groupId: true } }),
db.task.findMany({
where: { tenantId: tenant.id },
select: {
id: true,
linkedEntityId: true,
assignedToUserId: true,
assignedToGroupId: true,
completedAt: true,
createdAt: true,
updatedAt: true,
},
}),
db.scheduleEntry.findMany({
where: { tenantId: tenant.id },
select: { id: true, projektId: true, assignedUserId: true, startAt: true, endAt: true, calendarEventId: true, syncStatus: true },
}),
db.bookingPage.findMany({ where: { tenantId: tenant.id }, select: { id: true } }),
db.calendarEvent.findMany({
where: { tenantId: tenant.id },
select: { id: true, userId: true, startsAt: true, endsAt: true, internalEntityLink: true },
}),
db.appointment.findMany({
where: { tenantId: tenant.id },
select: { bookingPageId: true, providerUserId: true, kontaktId: true, internalCalendarEventId: true },
}),
db.rechnung.findMany({
where: { tenantId: tenant.id },
select: { id: true, projektId: true, dateiId: true, datum: true, faelligAm: true, createdAt: true, updatedAt: true },
}),
db.projektChecklistItem.findMany({ where: { tenantId: tenant.id }, select: { id: true, fotoFileId: true } }),
db.file.findMany({
where: { tenantId: tenant.id },
select: {
id: true,
displayName: true,
notes: true,
assignedToUserId: true,
storageKey: true,
mimeType: true,
sizeBytes: true,
checksumSha256: true,
scanStatus: true,
createdAt: true,
updatedAt: true,
},
}),
db.abnahmeProtokoll.findMany({
where: { tenantId: tenant.id },
select: { id: true, projektId: true, pdfFileId: true, dokumente: true, datum: true, gesperrtAt: true, createdAt: true, updatedAt: true },
}),
db.projektSignatureDocument.findMany({
where: { tenantId: tenant.id },
select: {
id: true,
projektId: true,
sourcePdfFileId: true,
sentAt: true,
completedAt: true,
createdAt: true,
updatedAt: true,
},
}),
db.lostReason.findMany({
where: { tenantId: tenant.id, archivedAt: null },
select: { name: true, position: true },
orderBy: { position: "asc" },
}),
db.role.findMany({
where: { tenantId: tenant.id, isTemplateOrigin: true },
select: {
name: true,
description: true,
type: true,
assignToNewUsers: true,
isDefault: true,
seatCategory: true,
isTemplateOrigin: true,
permissions: { select: { permission: { select: { name: true } } } },
},
}),
]);
const expectedEmployeeIds = new Set(options.graph.employees.map(({ id }) => id));
const tenantUserById = new Map(tenantUsers.map((tenantUser) => [tenantUser.userId, tenantUser] as const));
const seedUserById = new Map(seedUsers.map((user) => [user.id, user] as const));
const roleNamesByUserId = new Map<string, string[]>();
for (const assignment of roleAssignments) {
const names = roleNamesByUserId.get(assignment.userId) ?? [];
names.push(assignment.role.name);
roleNamesByUserId.set(assignment.userId, names);
}
for (const [index, employee] of options.graph.employees.entries()) {
const tenantUser = tenantUserById.get(employee.id);
if (
!tenantUser ||
tenantUser.status !== TENANT_USER_STATUS_ACTIVE ||
tenantUser.seatType !== employee.seatCategory ||
tenantUser.type !== (index === 0 ? TENANT_USER_TYPE.OWNER : TENANT_USER_TYPE.MEMBER)
) {
errors.push(`employee ${employee.id}: tenant membership does not match the seed graph`);
}
const user = seedUserById.get(employee.id);
if (
!user ||
user.email !== employee.email ||
user.firstName !== employee.firstName ||
user.lastName !== employee.lastName ||
user.phone !== employee.phone ||
user.locale !== employee.locale ||
!user.active ||
user.defaultTenantId !== tenant.id ||
!user.passwordHash?.startsWith("$argon2id$")
) {
errors.push(`employee ${employee.id}: global account does not match the active seed identity`);
}
const roleNames = (roleNamesByUserId.get(employee.id) ?? []).sort();
if (roleNames.length !== 1 || roleNames[0] !== employee.role) {
errors.push(`employee ${employee.id}: expected role ${employee.role}`);
}
}
for (const tenantUser of tenantUsers) {
if (!expectedEmployeeIds.has(tenantUser.userId)) errors.push(`employee ${tenantUser.userId}: unexpected tenant membership`);
}
for (const assignment of roleAssignments) {
if (!expectedEmployeeIds.has(assignment.userId)) errors.push(`employee ${assignment.userId}: unexpected role assignment`);
}
const userIds = new Set(tenantUsers.map(({ userId }) => userId));
const groupIds = new Set(groups.map(({ id }) => id));
const contactIds = new Set(contacts.map(({ id }) => id));
const projectIds = new Set(projects.map(({ id }) => id));
const bookingPageIds = new Set(bookingPages.map(({ id }) => id));
const calendarEventIds = new Set(calendarEvents.map(({ id }) => id));
const fileIds = new Set(files.map(({ id }) => id));
const sameInstant = (actual: Date | null, expectedValue: string | null): boolean =>
actual?.toISOString() === (expectedValue === null ? undefined : new Date(expectedValue).toISOString());
const expectedTasksById = new Map(options.operational.tasks.map((task) => [task.id, task] as const));
for (const task of tasks) {
const expectedTask = expectedTasksById.get(task.id);
if (
!expectedTask ||
!sameInstant(task.createdAt, expectedTask.createdAt) ||
!sameInstant(task.updatedAt, expectedTask.updatedAt) ||
!sameInstant(task.completedAt, expectedTask.completedAt)
) {
errors.push(`task ${task.id}: lifecycle timestamps do not match the operational graph`);
}
expectedTasksById.delete(task.id);
}
for (const taskId of expectedTasksById.keys()) errors.push(`task ${taskId}: missing`);
const expectedScheduleById = new Map(options.operational.scheduleEntries.map((entry) => [entry.id, entry] as const));
for (const entry of scheduleRows) {
const expectedEntry = expectedScheduleById.get(entry.id);
if (
!expectedEntry ||
entry.projektId !== expectedEntry.projectId ||
entry.assignedUserId !== expectedEntry.assignedUserId ||
!sameInstant(entry.startAt, expectedEntry.startAt) ||
!sameInstant(entry.endAt, expectedEntry.endAt) ||
entry.calendarEventId !== expectedEntry.calendarEventId ||
entry.syncStatus !== expectedEntry.syncStatus
) {
errors.push(`scheduleEntry ${entry.id}: calendar linkage does not match the operational graph`);
}
expectedScheduleById.delete(entry.id);
}
for (const entryId of expectedScheduleById.keys()) errors.push(`scheduleEntry ${entryId}: missing`);
const expectedInvoicesById = new Map(options.operational.invoices.map((invoice) => [invoice.id, invoice] as const));
for (const invoice of invoiceRows) {
const expectedInvoice = expectedInvoicesById.get(invoice.id);
if (
!expectedInvoice ||
invoice.projektId !== expectedInvoice.projectId ||
invoice.dateiId !== expectedInvoice.fileId ||
!sameInstant(invoice.datum, expectedInvoice.date) ||
!sameInstant(invoice.faelligAm, expectedInvoice.dueAt) ||
!sameInstant(invoice.createdAt, expectedInvoice.createdAt) ||
!sameInstant(invoice.updatedAt, expectedInvoice.updatedAt)
) {
errors.push(`invoice ${invoice.id}: lifecycle or file linkage does not match the operational graph`);
}
expectedInvoicesById.delete(invoice.id);
}
for (const invoiceId of expectedInvoicesById.keys()) errors.push(`invoice ${invoiceId}: missing`);
const expectedChecklistItemsById = new Map(options.operational.checklistItems.map((item) => [item.id, item] as const));
for (const item of checklistItemRows) {
const expectedItem = expectedChecklistItemsById.get(item.id);
if (!expectedItem || item.fotoFileId !== expectedItem.photoFileId) {
errors.push(`checklistItem ${item.id}: photo linkage does not match the operational graph`);
}
expectedChecklistItemsById.delete(item.id);
}
for (const itemId of expectedChecklistItemsById.keys()) errors.push(`checklistItem ${itemId}: missing`);
const expectedFilesById = new Map(options.operational.files.map((file) => [file.id, file] as const));
for (const file of files) {
const expectedFile = expectedFilesById.get(file.id);
if (!expectedFile) {
errors.push(`file ${file.id}: unexpected`);
continue;
}
if (
file.displayName !== expectedFile.displayName ||
file.notes !== expectedFile.notes ||
file.assignedToUserId !== expectedFile.assignedToUserId ||
file.storageKey !== seedFileStorageKey(tenant.id, file.id) ||
file.mimeType !== expectedFile.mimeType ||
file.sizeBytes !== expectedFile.sizeBytes ||
file.checksumSha256 !== expectedFile.checksumSha256 ||
file.scanStatus !== "clean" ||
!sameInstant(file.createdAt, expectedFile.createdAt) ||
!sameInstant(file.updatedAt, expectedFile.updatedAt)
) {
errors.push(`file ${file.id}: metadata does not match the operational graph`);
}
expectedFilesById.delete(file.id);
}
for (const fileId of expectedFilesById.keys()) errors.push(`file ${fileId}: missing`);
const expectedAcceptancesById = new Map(options.operational.acceptances.map((acceptance) => [acceptance.id, acceptance] as const));
for (const acceptance of acceptanceRows) {
const expectedAcceptance = expectedAcceptancesById.get(acceptance.id);
if (
!expectedAcceptance ||
acceptance.projektId !== expectedAcceptance.projectId ||
acceptance.pdfFileId !== expectedAcceptance.documentFileId ||
JSON.stringify(acceptance.dokumente) !== "[]" ||
!sameInstant(acceptance.datum, expectedAcceptance.date) ||
!sameInstant(acceptance.gesperrtAt, expectedAcceptance.updatedAt) ||
!sameInstant(acceptance.createdAt, expectedAcceptance.createdAt) ||
!sameInstant(acceptance.updatedAt, expectedAcceptance.updatedAt)
) {
errors.push(`acceptance ${acceptance.id}: lifecycle or document linkage does not match the operational graph`);
}
expectedAcceptancesById.delete(acceptance.id);
}
for (const acceptanceId of expectedAcceptancesById.keys()) errors.push(`acceptance ${acceptanceId}: missing`);
const expectedSignaturesById = new Map(options.operational.signatureDocuments.map((document) => [document.id, document] as const));
for (const document of signatureDocuments) {
const expectedDocument = expectedSignaturesById.get(document.id);
if (
!expectedDocument ||
document.projektId !== expectedDocument.projectId ||
document.sourcePdfFileId !== expectedDocument.sourceFileId ||
!sameInstant(document.sentAt, expectedDocument.sentAt) ||
!sameInstant(document.completedAt, expectedDocument.completedAt) ||
!sameInstant(document.createdAt, expectedDocument.createdAt) ||
!sameInstant(document.updatedAt, expectedDocument.updatedAt)
) {
errors.push(`signatureDocument ${document.id}: lifecycle or source linkage does not match the operational graph`);
}
expectedSignaturesById.delete(document.id);
}
for (const documentId of expectedSignaturesById.keys()) errors.push(`signatureDocument ${documentId}: missing`);
if (projectContacts.some((link) => !projectIds.has(link.projektId) || !contactIds.has(link.kontaktId)))
errors.push("projectContacts contain dangling references");
if (
projectMembers.some(
(member) =>
!projectIds.has(member.projektId) ||
(member.userId !== null && !userIds.has(member.userId)) ||
(member.groupId !== null && !groupIds.has(member.groupId))
)
)
errors.push("projectMembers contain dangling references");
if (
tasks.some(
(task) =>
task.linkedEntityId === null ||
!projectIds.has(task.linkedEntityId) ||
(task.assignedToUserId !== null && !userIds.has(task.assignedToUserId)) ||
(task.assignedToGroupId !== null && !groupIds.has(task.assignedToGroupId))
)
)
errors.push("tasks contain dangling references");
if (
appointments.some(
(appointment) =>
!bookingPageIds.has(appointment.bookingPageId) ||
!userIds.has(appointment.providerUserId) ||
(appointment.kontaktId !== null && !contactIds.has(appointment.kontaktId)) ||
(appointment.internalCalendarEventId !== null && !calendarEventIds.has(appointment.internalCalendarEventId))
)
)
errors.push("bookingAppointments contain dangling references");
if (signatureDocuments.some((document) => !projectIds.has(document.projektId) || !fileIds.has(document.sourcePdfFileId))) {
errors.push("signatureDocuments contain dangling references");
}
const expectedLostReasons = STAGING_LOST_REASONS.map((name, position) => ({ name, position: (position + 1) * 10 }));
if (JSON.stringify(lostReasonRows) !== JSON.stringify(expectedLostReasons)) {
errors.push("lostReasons do not match the stable catalog order");
}
const actualTemplatePermissions = new Map(
templateRoles.map((role) => [role.name, role.permissions.map(({ permission }) => permission.name).sort()] as const)
);
for (const role of shkTemplateRoleSeeds) {
const actualRole = templateRoles.find((candidate) => candidate.name === role.name);
const actualPermissions = actualTemplatePermissions.get(role.name);
if (!actualRole || !actualPermissions) {
errors.push(`templateRole ${role.name}: missing`);
continue;
}
if (
actualRole.description !== role.description ||
actualRole.type !== "app" ||
actualRole.assignToNewUsers ||
actualRole.isDefault ||
actualRole.seatCategory !== role.seatCategory ||
!actualRole.isTemplateOrigin
) {
errors.push(`templateRole ${role.name}: metadata does not match the catalog`);
}
const expectedPermissions = [...role.permissionKeys].sort();
if (JSON.stringify(actualPermissions) !== JSON.stringify(expectedPermissions)) {
errors.push(`templateRole ${role.name}: permission set does not match the catalog`);
}
actualTemplatePermissions.delete(role.name);
}
for (const extraRoleName of actualTemplatePermissions.keys()) errors.push(`templateRole ${extraRoleName}: unexpected`);
const expectedStageCounts: Record<string, number> = {
"rheinmain-shk-v1-stage-sales-neu": 47,
"rheinmain-shk-v1-stage-sales-qualifiziert": 59,
"rheinmain-shk-v1-stage-sales-angebot": 73,
"rheinmain-shk-v1-stage-sales-gewonnen": 112,
"rheinmain-shk-v1-stage-sales-verloren": 28,
"rheinmain-shk-v1-stage-auftrag-planung": 31,
"rheinmain-shk-v1-stage-auftrag-montage": 67,
"rheinmain-shk-v1-stage-auftrag-abnahme": 39,
"rheinmain-shk-v1-stage-auftrag-abgeschlossen": 69,
"rheinmain-shk-v1-stage-auftrag-storniert": 12,
};
const actualStageCounts = projects.reduce<Record<string, number>>((result, project) => {
result[project.stageId] = (result[project.stageId] ?? 0) + 1;
return result;
}, {});
for (const [stageIdValue, expectedCount] of Object.entries(expectedStageCounts)) {
if ((actualStageCounts[stageIdValue] ?? 0) !== expectedCount)
errors.push(`${stageIdValue}: expected ${expectedCount}, received ${actualStageCounts[stageIdValue] ?? 0}`);
}
const pendingSearchEvents = counts.pendingSearchEvents ?? 0;
if (pendingSearchEvents > 0) errors.push(`pendingSearchEvents: expected 0, received ${pendingSearchEvents}`);
const failedSearchEvents = counts.failedSearchEvents ?? 0;
if (failedSearchEvents > 0) errors.push(`failedSearchEvents: expected 0, received ${failedSearchEvents}`);
return {
tenantSlug,
counts,
pendingSearchEvents,
failedSearchEvents,
valid: errors.length === 0,
errors,
};
}
export async function waitForSearchOutbox(db: DbClient, tenantId: string, timeoutMs = 300_000): Promise<number> {
const deadline = Date.now() + timeoutMs;
let pending = 0;
do {
pending = await db.outboxEvent.count({ where: { tenantId, eventType: { startsWith: "search.index." }, status: { in: ["PENDING", "PROCESSING"] } } });
if (pending === 0 || timeoutMs <= 0) return pending;
await new Promise((resolve) => setTimeout(resolve, 1_000));
} while (Date.now() < deadline);
return pending;
}