2259 lines
89 KiB
TypeScript
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;
|
|
}
|