Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/db/seed/SeedService.ts
T

973 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from "node:crypto";
import bcrypt from "bcryptjs";
import { TenantUserJoined } from "~/application/enums/tenants/TenantUserJoined";
import { TenantUserType } from "~/application/enums/tenants/TenantUserType";
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
import { getAvailableTenantInboundAddress } from "~/utils/services/emailService";
import { getEntitlementPlanBillingProviderId, seedStandardEntitlements, type EntitlementsSeedDiagnosticEvent } from "@hivegrid/db/entitlements-seed.server";
import { runDbSeed, type DbSeedDiagnosticEvent } from "@hivegrid/db/seed.server";
import { entitlementPlanIds, featureKeys, productBlueprintIds, type EntitlementPlanId } from "@hivegrid/entitlements";
import { buildCatalogSeedPlan } from "@hivegrid/integrations";
import { seedRolesAndPermissions, type RolesAndPermissionsSeedDiagnosticEvent } from "~/utils/services/rolesAndPermissionsService";
import { db } from "~/utils/db.server";
import { seedShkCustomFieldTemplate } from "~/utils/integrations/crm/customFieldService.server";
import { createScopedLogger } from "~/utils/logger.server";
import {
assertLocalCredentialsDatabaseMarker,
assertLocalCredentialsEnvironment,
} from "./localCredentialsEnvironment.server";
import { seedStagingDomainDemo } from "./stagingDomainDemoSeed.server";
import {
automationTemplateCatalog,
automationTemplateFlowData,
} from "~/services/automation/automation.registry";
const seedLogger = createScopedLogger("seed");
/**
* Hashes an email (lower-cased + trimmed) so seed logs cannot leak the
* actual address but operators can still correlate diagnostics.
*/
function emailFingerprint(email: string): string {
return createHash("sha256").update(email.trim().toLowerCase(), "utf8").digest("hex").slice(0, 12);
}
const DEFAULT_ADMIN_EMAIL = "admin@email.com";
const DEFAULT_ADMIN_PASSWORD = "password";
const localDemoSeedUsers = [
{
userType: "app" as const,
firstName: "John",
lastName: "Doe",
email: "john.doe@company.com",
password: "password",
reactivateExisting: true,
},
{
userType: "app" as const,
firstName: "Luna",
lastName: "Davis",
email: "luna.davis@company.com",
password: "password",
reactivateExisting: true,
},
];
const demoCrmTenants = [
{ slug: "demo-starter-crm", name: "Demo Starter CRM", planId: entitlementPlanIds.starter },
{ slug: "demo-pro-crm", name: "Demo Pro CRM", planId: entitlementPlanIds.pro },
{ slug: "demo-business-crm", name: "Demo Business CRM", planId: entitlementPlanIds.business },
] as const;
const managedDemoProTenantSlug = "demo-pro-crm";
const demoTenantRoleSeeds = [
{
name: "CRM Manager",
description: "Owns CRM records, imports, and tenant team coordination in the demo workspace.",
permissions: ["app.dashboard.view", "app.settings.members.view", "app.settings.roles.view"],
},
{
name: "Support Agent",
description: "Handles tenant support tickets and reads the tenant knowledge base in the demo workspace.",
permissions: ["app.dashboard.view"],
},
] as const;
const demoKnowledgeBaseCategoryTitle = "Product Core";
const demoKnowledgeBaseCategoryDescription = "Plan gates, tenant roles, support, and CRM SaaS setup.";
const demoKnowledgeBaseArticleTitle = "Plan gates overview";
const demoSupportTicketSubjects = {
welcome: "Welcome to the CRM SaaS demo workspace",
planGates: "Verify plan gates and tenant roles",
} as const;
const demoSupportTicketPlanGatesMessage =
"Use this ticket to confirm blueprint, plan, tenant override, and tenant-owned role access in the seeded workspace.";
// AK-P16a/AK-P16b: booking + calendar demo data for the demo tenants so
// /bookings, /bookings/pages, /availability and /calendar are populated out of
// the box. The rich local demo seed (scripts/seed-hivegrid-demo.ts) refuses to
// run with NODE_ENV=production, so the staging demo path lives here.
// Deterministic `seed-<tenant>-*` ids keep every upsert idempotent across
// SEED_ON_BOOT runs; day offsets are re-resolved on each run so "upcoming"
// appointments stay upcoming.
const demoBookingTimezone = "Europe/Berlin";
const demoBookingPageSeeds = [
{
slug: "beratungstermin",
title: "Beratungstermin", // demo seed copy
description: "Kostenloses Erstgespräch für neue Anfragen (Demo).",
durationMinutes: 30,
isPublished: true,
},
{
slug: "vor-ort-termin",
title: "Vor-Ort-Termin", // demo seed copy
description: "Termin beim Endkunden vor Ort (Demo, unveröffentlicht).",
durationMinutes: 60,
isPublished: false,
},
] as const;
const demoAppointmentSeeds = [
{
key: "upcoming-consult",
pageSlug: "beratungstermin",
providerIndex: 0,
dayOffset: 2,
startHour: 10,
status: "scheduled",
intakeAnswers: { vorname: "Sabine", nachname: "Wagner", email: "sabine.wagner@example.test", anliegen: "Bad-Sanierung besprechen." },
},
{
key: "upcoming-onsite",
pageSlug: "vor-ort-termin",
providerIndex: 1,
dayOffset: 5,
startHour: 8,
status: "scheduled",
intakeAnswers: { vorname: "Thomas", nachname: "Schneider", email: "thomas.schneider@example.test", anliegen: "Heizungstausch vor Ort ansehen." },
},
{
key: "past-held",
pageSlug: "beratungstermin",
providerIndex: 0,
dayOffset: -3,
startHour: 14,
status: "held",
intakeAnswers: { vorname: "Birgit", nachname: "Sommer", email: "birgit.sommer@example.test", anliegen: "Jahreswartung planen." },
},
{
key: "cancelled-consult",
pageSlug: "beratungstermin",
providerIndex: 1,
dayOffset: -1,
startHour: 9,
status: "cancelled",
intakeAnswers: { vorname: "Peter", nachname: "Krause", email: "peter.krause@example.test", anliegen: "Terminanfrage (storniert)." },
},
] as const;
const demoAvailabilityWeekdays = ["monday", "tuesday", "wednesday", "thursday", "friday"] as const;
const demoAvailabilityExceptionSeeds = [
{ key: "day-off", providerIndex: 0, dayOffset: 7, kind: "unavailable", startTime: null, endTime: null, reason: "Urlaubstag (Demo)" },
{ key: "short-day", providerIndex: 1, dayOffset: 3, kind: "custom_window", startTime: "10:00", endTime: "14:00", reason: "Verkürzter Tag (Demo)" },
] as const;
async function useCanonicalLocalCredentials() {
if (process.env.LOCAL_DEV_CREDENTIALS !== "1") {
return false;
}
assertLocalCredentialsEnvironment(process.env);
await assertLocalCredentialsDatabaseMarker(db);
return true;
}
function getAdminSeedUser(canonicalLocalCredentials: boolean) {
if (canonicalLocalCredentials) {
return {
email: DEFAULT_ADMIN_EMAIL,
password: DEFAULT_ADMIN_PASSWORD,
firstName: "Admin",
lastName: "User",
};
}
return {
email: process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL,
password: process.env.SEED_ADMIN_PASSWORD ?? DEFAULT_ADMIN_PASSWORD,
firstName: "Admin",
lastName: "User",
};
}
async function seed() {
const canonicalLocalCredentials = await useCanonicalLocalCredentials();
const adminUser = getAdminSeedUser(canonicalLocalCredentials);
const shouldSeedDemoData = shouldSeedCrmDemoData();
const bootSeedDemoCrmTenants = getBootSeedDemoCrmTenants();
const seedCredentials = shouldSeedDemoData
? [
{ label: "admin", email: adminUser.email, password: adminUser.password },
...localDemoSeedUsers.map((user) => ({ label: user.email, email: user.email, password: user.password })),
]
: [{ label: "admin", email: adminUser.email, password: adminUser.password }];
return runDbSeed({
nodeEnv: getSeedSafetyEnvironment(),
adminEmail: adminUser.email,
adminPassword: adminUser.password,
seedCredentials,
diagnostics: logDbSeedDiagnostic,
seed: async () => {
const admin = await createUser({ userType: "admin", canonicalLocalCredentials, ...adminUser });
let demoUsers: Awaited<ReturnType<typeof createUser>>[] = [];
if (shouldSeedDemoData) {
seedLogger.info({ op: "seed.createDemoUsers", count: localDemoSeedUsers.length }, "Creating CRM SaaS demo users with tenants");
demoUsers = await Promise.all(localDemoSeedUsers.map((user) => createUser({ ...user, canonicalLocalCredentials })));
const [user1, user2] = demoUsers;
await Promise.all(
bootSeedDemoCrmTenants.map((tenant, index) =>
createTenant(tenant.slug, tenant.name, [
{ ...admin, type: TenantUserType.ADMIN },
{ ...user1, type: index === 0 ? TenantUserType.ADMIN : TenantUserType.OWNER },
{ ...user2, type: TenantUserType.MEMBER },
])
)
);
}
// Permissions
await seedRolesAndPermissions({
adminEmail: adminUser.email,
diagnostics: logRolesAndPermissionsSeedDiagnostic,
...(process.env.SEED_DEMO_PRO_MANAGED === "true" ? { excludedTenantSlugs: [managedDemoProTenantSlug] } : {}),
});
await seedStandardEntitlements({
client: db,
includeCustomerPortalCommercialProducts: true,
includeEmailMarketingCommercialProducts: true,
diagnostics: logEntitlementsSeedDiagnostic,
});
// R-P5: global App definition table from the code catalog (idempotent by
// unique key; the tenant runtime keeps reading INTEGRATION_CATALOG).
await seedIntegrationCatalog();
await seedAutomationTemplateCatalog();
if (shouldSeedDemoData) {
await Promise.all(bootSeedDemoCrmTenants.map((tenant) => assignTenantPlan(tenant.slug, tenant.planId)));
await Promise.all(bootSeedDemoCrmTenants.map((tenant) => seedDemoProductCoreTenant(tenant.slug, tenant.planId, demoUsers)));
}
},
});
}
function getBootSeedDemoCrmTenants() {
if (process.env.SEED_DEMO_PRO_MANAGED !== "true") {
return demoCrmTenants;
}
// The comprehensive staging seed owns every tenant-scoped row in demo-pro-crm.
// Excluding it here keeps later web boots from reintroducing legacy members,
// plans, bookings, projects, or other product-core fixtures.
return demoCrmTenants.filter((tenant) => tenant.slug !== managedDemoProTenantSlug);
}
/** Upsert the code catalog into the global `App` table (R-P5, no RLS/tenant). */
async function seedIntegrationCatalog() {
const plan = buildCatalogSeedPlan();
for (const entry of plan) {
const { key, connectionSchema, ...rest } = entry;
// Prisma's InputJsonValue rejects readonly structures — deep-clone to plain.
const data = { ...rest, connectionSchema: JSON.parse(JSON.stringify(connectionSchema)) as object };
await db.app.upsert({ where: { key }, create: { key, ...data }, update: data });
}
seedLogger.info({ op: "seed.integrationCatalog", count: plan.length }, "Upserted integration catalog into App table");
}
/** Seed global templates through the privileged deployment seed, never a tenant request. */
async function seedAutomationTemplateCatalog() {
for (const template of automationTemplateCatalog) {
const flowData = JSON.parse(JSON.stringify(automationTemplateFlowData(template))) as object;
// Matching the old key-shaped name too heals rows that were seeded before
// display text replaced the translation key.
const existing = await db.automationTemplate.findFirst({
where: { tenantId: null, name: { in: [template.defaultName, template.nameKey] }, archivedAt: null },
select: { id: true },
});
const data = {
name: template.defaultName,
description: template.defaultDescription,
category: template.category,
flowData,
isPublished: true,
isDemoData: true,
};
if (existing) {
await db.automationTemplate.update({ where: { id: existing.id }, data });
} else {
await db.automationTemplate.create({
data: { tenantId: null, ...data },
});
}
}
seedLogger.info(
{ op: "seed.automationTemplateCatalog", count: automationTemplateCatalog.length },
"Upserted global automation template catalog",
);
}
function shouldSeedCrmDemoData() {
if (process.env.SEED_DEMO_DATA !== "true") {
return false;
}
if (process.env.NODE_ENV !== "production") {
return true;
}
if (process.env.ALLOW_STAGING_DEMO_SEED === "true") {
return true;
}
throw new Error("Refusing to seed demo data in production without ALLOW_STAGING_DEMO_SEED=true.");
}
function getSeedSafetyEnvironment() {
if (process.env.NODE_ENV === "production" && process.env.SEED_DEMO_DATA === "true" && process.env.ALLOW_STAGING_DEMO_SEED === "true") {
return "staging-demo";
}
return process.env.NODE_ENV ?? "development";
}
function logDbSeedDiagnostic(event: DbSeedDiagnosticEvent) {
const { phase, nodeEnv, adminEmail } = event;
// Never log raw adminEmail (PII). Use fingerprint hash for correlation.
seedLogger.info(
{ op: "db-seed", phase, nodeEnv, adminEmailFingerprint: adminEmail ? emailFingerprint(adminEmail) : null },
"DB seed event",
);
}
function logRolesAndPermissionsSeedDiagnostic(event: RolesAndPermissionsSeedDiagnosticEvent) {
const {
phase,
adminRoleCount,
appRoleCount,
defaultPermissionCount,
dynamicEntityPermissionCount,
adminUserAssignmentCount,
tenantUserAssignmentCount,
error,
} = event;
seedLogger.info(
{
op: "seed.rolesAndPermissions",
phase,
adminRoleCount,
appRoleCount,
defaultPermissionCount,
dynamicEntityPermissionCount,
adminUserAssignmentCount,
tenantUserAssignmentCount,
...(error ? { err: error } : {}),
},
"Roles and permissions seed event",
);
}
function logEntitlementsSeedDiagnostic(event: EntitlementsSeedDiagnosticEvent) {
const { phase, planCount, featureCount, createdProducts, updatedProducts, deletedFeatures, createdFeatures, planId, error } = event;
seedLogger.info(
{
op: "seed.entitlements",
phase,
planCount,
featureCount,
createdProducts,
updatedProducts,
deletedFeatures,
createdFeatures,
...(planId ? { planId } : {}),
...(error ? { err: error } : {}),
},
"Entitlements seed event",
);
}
async function createUser({
firstName,
lastName,
email,
password,
userType,
reactivateExisting = false,
canonicalLocalCredentials = false,
}: {
firstName: string;
lastName: string;
email: string;
password: string;
userType: "admin" | "app";
reactivateExisting?: boolean;
canonicalLocalCredentials?: boolean;
}) {
// Never log raw `email` (PII) and never log `password` (secret).
// Use email fingerprint hash so operators can still correlate seed events.
seedLogger.info(
{ op: "seed.createUser", userType, emailFingerprint: emailFingerprint(email) },
userType === "admin" ? "Seeding admin user" : "Seeding app user",
);
const salt = await bcrypt.genSalt(10);
const passwordHash = await bcrypt.hash(password, salt);
let user = await db.user.findUnique({
where: { email },
});
if (user) {
if (canonicalLocalCredentials) {
user = await db.user.update({
where: { id: user.id },
data: {
passwordHash,
...(userType === "admin" || reactivateExisting ? { active: true } : {}),
},
});
}
// Only explicitly managed demo identities opt into reactivation. Existing
// admin or customer accounts may have been disabled intentionally.
if (!canonicalLocalCredentials && reactivateExisting && user.active === false) {
user = await db.user.update({
where: { id: user.id },
data: { active: true },
});
}
const isAdmin = await db.adminUser.findUnique({ where: { id: user.id } });
if (!isAdmin && userType === "admin") {
if (process.env.NODE_ENV === "production") {
throw new Error("Production seed refused to promote an existing non-admin user. Choose a new SEED_ADMIN_EMAIL or bootstrap the admin user explicitly.");
}
seedLogger.info(
{ op: "seed.promoteUserToAdmin", emailFingerprint: emailFingerprint(email) },
"User already exists; promoting to admin",
);
await db.adminUser.create({
data: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
status: "active",
},
});
} else {
seedLogger.info(
{ op: "seed.userExists", userType, emailFingerprint: emailFingerprint(email) },
"User already exists",
);
}
return user;
}
user = await db.user.create({
data: {
email,
passwordHash,
avatar: "",
firstName,
lastName,
phone: "",
active: true,
},
});
if (userType === "admin") {
await db.adminUser.create({
data: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
status: "active",
},
});
}
return user;
}
async function createTenant(slug: string, name: string, users: { id: string; type: TenantUserType }[]) {
seedLogger.info({ op: "seed.createTenant", tenantSlug: slug, tenantName: name }, "Creating tenant");
let tenant = await db.tenant.findUnique({
where: { slug },
});
if (tenant) {
seedLogger.info({ op: "seed.tenantExists", tenantSlug: slug }, "Tenant already exists");
await db.tenant.update({
where: { id: tenant.id },
data: { active: true },
});
await ensureTenantSubscription(tenant.id);
await ensureTenantInboundAddress(tenant.id, name);
await ensureTenantUsers(tenant.id, users);
return tenant;
}
const address = await getAvailableTenantInboundAddress(name);
tenant = await db.tenant.create({
data: {
name,
slug,
icon: "",
active: true,
inboundAddresses: {
create: {
address,
},
},
},
});
let tenantId = tenant.id;
await ensureTenantSubscription(tenantId);
await ensureTenantUsers(tenantId, users);
return tenant;
}
async function ensureTenantSubscription(tenantId: string) {
const existing = await db.tenantSubscription.findUnique({ where: { tenantId } });
if (existing) {
return existing;
}
return db.tenantSubscription.create({
data: {
tenantId,
billingProviderCustomerId: "",
},
});
}
async function ensureTenantInboundAddress(tenantId: string, tenantName: string) {
const existing = await db.tenantInboundAddress.findFirst({ where: { tenantId } });
if (existing) {
return existing;
}
const address = await getAvailableTenantInboundAddress(tenantName);
return db.tenantInboundAddress.create({
data: {
tenantId,
address,
},
});
}
async function ensureTenantUsers(tenantId: string, users: { id: string; type: TenantUserType }[]) {
for (const user of users) {
const tenantUser = await db.tenantUser.findFirst({
where: { tenantId, userId: user.id },
});
if (tenantUser) {
seedLogger.info({ op: "seed.tenantUserExists", userId: user.id, tenantId }, "User already in tenant");
continue;
}
await db.tenantUser.create({
data: {
tenantId,
userId: user.id,
type: user.type,
joined: TenantUserJoined.CREATOR,
status: TenantUserStatus.ACTIVE,
},
});
}
}
async function assignTenantPlan(tenantSlug: string, planId: EntitlementPlanId) {
const tenant = await db.tenant.findUnique({
where: { slug: tenantSlug },
include: { subscription: { include: { products: true } } },
});
if (!tenant?.subscription) {
return;
}
if (tenant.subscription.lastPaymentExternalId?.startsWith("hgco_") || tenant.subscription.lastInvoiceExternalId?.startsWith("hgco_")) {
return;
}
const product = await db.subscriptionProduct.findFirst({
where: { billingProviderId: getEntitlementPlanBillingProviderId({ id: planId }) },
select: { id: true },
});
if (!product) {
return;
}
const existingProduct = tenant.subscription.products.find((item) => item.subscriptionProductId === product.id && !item.endsAt);
if (existingProduct) {
return;
}
await db.tenantSubscriptionProduct.create({
data: {
tenantSubscriptionId: tenant.subscription.id,
subscriptionProductId: product.id,
billingProviderSubscriptionId: null,
quantity: 1,
fromCheckoutSessionId: null,
customEntitlementPlanId: planId,
},
});
}
async function seedDemoProductCoreTenant(tenantSlug: string, planId: EntitlementPlanId, demoUsers: { id: string }[]) {
const tenant = await db.tenant.findUnique({
where: { slug: tenantSlug },
select: { id: true, name: true, slug: true },
});
if (!tenant) {
return;
}
await Promise.all([
seedDemoTenantProductConfiguration(tenant.id, planId),
seedDemoTenantRoles(tenant.id, demoUsers),
seedDemoTenantKnowledgeBase(tenant.id, tenant.slug, tenant.name, planId),
seedDemoSupportTickets(tenant.id, tenant.slug, planId, demoUsers[0]?.id),
// R-P6 (DoD 6): seed the tenant-scoped SHK custom-field template as demo
// data (`isDemoData = true`) instead of the legacy entity-builder CRM
// entities. The template is intentionally empty until the Geerkens
// workshop (E3), so this is currently an idempotent no-op.
seedShkCustomFieldTemplate({ tenantId: tenant.id, isDemoData: true }),
planId === entitlementPlanIds.business && demoUsers[0]
? seedStagingDomainDemo({ tenantId: tenant.id, ownerUserId: demoUsers[0].id })
: Promise.resolve(),
seedDemoTenantBookingData(tenant.id, tenant.slug, demoUsers),
]);
}
/** Local wall-clock date `dayOffset` days from now (mirrors the local demo seed). */
function demoBookingDate(dayOffset: number, hour: number, minute = 0): Date {
const value = new Date();
value.setDate(value.getDate() + dayOffset);
value.setHours(hour, minute, 0, 0);
return value;
}
/** Calendar-date-only value at UTC midnight (AvailabilityException stores `@db.Date`). */
function demoBookingDateOnlyUtc(dayOffset: number): Date {
const value = new Date();
value.setDate(value.getDate() + dayOffset);
return new Date(Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()));
}
async function seedDemoTenantBookingData(tenantId: string, tenantSlug: string, demoUsers: { id: string }[]) {
const providers = demoUsers.slice(0, 2);
if (providers.length === 0) {
return;
}
// Booking pages + provider assignments (unique per tenantId+slug).
const pageBySlug = new Map<string, { id: string; durationMinutes: number }>();
for (const page of demoBookingPageSeeds) {
const data = {
title: page.title,
description: page.description,
durationMinutes: page.durationMinutes,
confirmationEmailSubject: `Terminbestätigung: ${page.title}`,
confirmationEmailBody: "Vielen Dank für deine Buchung. Die Termindetails findest du im Kalenderanhang (.ics).",
isPublished: page.isPublished,
isDemoData: true,
createdByUserId: providers[0].id,
};
const row = await db.bookingPage.upsert({
where: { tenantId_slug: { tenantId, slug: page.slug } },
create: { tenantId, slug: page.slug, ...data },
update: data,
select: { id: true },
});
pageBySlug.set(page.slug, { id: row.id, durationMinutes: page.durationMinutes });
for (const [index, provider] of providers.entries()) {
await db.bookingPageProvider.upsert({
where: { bookingPageId_providerUserId: { bookingPageId: row.id, providerUserId: provider.id } },
create: { tenantId, bookingPageId: row.id, providerUserId: provider.id, displayOrder: index, isActive: true },
update: { displayOrder: index, isActive: true },
});
}
}
// Weekly Mo–Fr availability per provider so the public slot engine and the
// /availability editor have data. AvailabilityRule has no natural unique key,
// so deterministic ids keep the upserts idempotent.
for (const [providerIndex, provider] of providers.entries()) {
for (const day of demoAvailabilityWeekdays) {
const id = `seed-${tenantSlug}-avail-${providerIndex}-${day}`;
const data = { tenantId, providerUserId: provider.id, dayOfWeek: day, startTime: "09:00", endTime: "17:00", timezone: demoBookingTimezone };
await db.availabilityRule.upsert({ where: { id }, create: { id, ...data }, update: data });
}
}
for (const exception of demoAvailabilityExceptionSeeds) {
const provider = providers[exception.providerIndex % providers.length];
const id = `seed-${tenantSlug}-exception-${exception.key}`;
const data = {
tenantId,
providerUserId: provider.id,
date: demoBookingDateOnlyUtc(exception.dayOffset),
kind: exception.kind,
startTime: exception.startTime,
endTime: exception.endTime,
reason: exception.reason,
timezone: demoBookingTimezone,
};
await db.availabilityException.upsert({ where: { id }, create: { id, ...data }, update: data });
}
// Appointments across the list tabs (upcoming/past/cancelled) + a local
// CalendarEvent mirror per active appointment so /calendar shows sources
// (mirrors what BookingSubmitService does on a real booking).
for (const appointment of demoAppointmentSeeds) {
const page = pageBySlug.get(appointment.pageSlug);
const provider = providers[appointment.providerIndex % providers.length];
if (!page) {
continue;
}
const appointmentId = `seed-${tenantSlug}-appt-${appointment.key}`;
const calendarEventId = `seed-${tenantSlug}-cal-${appointment.key}`;
const startAt = demoBookingDate(appointment.dayOffset, appointment.startHour);
const endAt = new Date(startAt.getTime() + page.durationMinutes * 60_000);
const cancelled = appointment.status === "cancelled";
if (cancelled) {
// Real cancels remove the availability-blocking mirror — keep parity.
await db.calendarEvent.deleteMany({ where: { id: calendarEventId } });
} else {
const eventData = {
tenantId,
userId: provider.id,
source: "hivegrid_internal" as const,
title: `${demoBookingPageSeeds.find((p) => p.slug === appointment.pageSlug)?.title ?? "Buchung"} (Demo)`,
startsAt: startAt,
endsAt: endAt,
timeZone: demoBookingTimezone,
isBusyForAvailability: true,
internalEntityLink: { type: "appointment", id: appointmentId },
isDemoData: true,
};
await db.calendarEvent.upsert({ where: { id: calendarEventId }, create: { id: calendarEventId, ...eventData }, update: eventData });
}
const data = {
tenantId,
bookingPageId: page.id,
providerUserId: provider.id,
startAt,
endAt,
timezone: demoBookingTimezone,
status: appointment.status,
intakeAnswers: { ...appointment.intakeAnswers },
internalCalendarEventId: cancelled ? null : calendarEventId,
cancelledAt: cancelled ? demoBookingDate(appointment.dayOffset - 1, 12) : null,
cancelledByKind: cancelled ? ("customer" as const) : null,
cancellationReason: cancelled ? "Vom Endkunden storniert (Demo)." : null,
isDemoData: true,
};
await db.appointment.upsert({ where: { id: appointmentId }, create: { id: appointmentId, ...data }, update: data });
}
// One external busy block per tenant so the calendar grid shows both source
// markers (internal + external) without a live Google connection.
const externalEventId = `seed-${tenantSlug}-cal-external`;
const externalStartsAt = demoBookingDate(1, 12);
const externalData = {
tenantId,
userId: providers[0].id,
source: "external_synced" as const,
title: "Externer Termin (Demo)",
startsAt: externalStartsAt,
endsAt: new Date(externalStartsAt.getTime() + 90 * 60_000),
timeZone: demoBookingTimezone,
isBusyForAvailability: true,
isDemoData: true,
};
await db.calendarEvent.upsert({ where: { id: externalEventId }, create: { id: externalEventId, ...externalData }, update: externalData });
}
async function seedDemoTenantProductConfiguration(tenantId: string, planId: EntitlementPlanId) {
await db.tenantProductConfiguration.upsert({
where: { tenantId },
create: {
tenantId,
blueprintId: productBlueprintIds.crmSaas,
notes: `Seeded CRM SaaS product-core demo configuration for the ${planId} plan.`,
},
update: {
blueprintId: productBlueprintIds.crmSaas,
notes: `Seeded CRM SaaS product-core demo configuration for the ${planId} plan.`,
},
});
const overrides =
planId === entitlementPlanIds.business
? [{ featureKey: featureKeys.mcp, override: "enabled", reason: "Business demo tenant shows blueprint-enabled MCP access." }]
: [{ featureKey: featureKeys.workflowEngine, override: "disabled", reason: `${planId} demo tenant keeps workflow automation plan-gated.` }];
for (const override of overrides) {
await db.tenantFeatureOverride.upsert({
where: { tenantId_featureKey: { tenantId, featureKey: override.featureKey } },
create: { tenantId, ...override },
update: { override: override.override, reason: override.reason },
});
}
}
async function seedDemoTenantRoles(tenantId: string, demoUsers: { id: string }[]) {
const permissions = await db.permission.findMany({
where: {
name: {
in: [...new Set(demoTenantRoleSeeds.flatMap((role) => role.permissions))],
},
},
select: { id: true, name: true },
});
const permissionIdByName = new Map(permissions.map((permission) => [permission.name, permission.id]));
for (const [index, roleSeed] of demoTenantRoleSeeds.entries()) {
let role = await db.role.findFirst({
where: { tenantId, name: roleSeed.name, type: "app" },
select: { id: true },
});
if (!role) {
role = await db.role.create({
data: {
tenantId,
name: roleSeed.name,
description: roleSeed.description,
type: "app",
assignToNewUsers: index === 1,
isDefault: false,
order: 100 + index,
},
select: { id: true },
});
}
for (const permissionName of roleSeed.permissions) {
const permissionId = permissionIdByName.get(permissionName);
if (!permissionId) {
continue;
}
const existing = await db.rolePermission.findFirst({ where: { roleId: role.id, permissionId } });
if (!existing) {
await db.rolePermission.create({ data: { roleId: role.id, permissionId } });
}
}
const user = demoUsers[index];
if (user) {
const existing = await db.userRole.findFirst({ where: { tenantId, userId: user.id, roleId: role.id } });
if (!existing) {
await db.userRole.create({ data: { tenantId, userId: user.id, roleId: role.id } });
}
}
}
}
async function seedDemoTenantKnowledgeBase(tenantId: string, tenantSlug: string, tenantName: string, planId: EntitlementPlanId) {
const ownerKey = `tenant:${tenantId}`;
const slug = `${tenantSlug}-kb`;
const knowledgeBase = await db.knowledgeBase.upsert({
where: { ownerKey_slug: { ownerKey, slug } },
create: {
tenantId,
ownerKey,
basePath: "help",
slug,
title: `${tenantName} Knowledge Base`,
description: `Tenant-owned product-core demo knowledge base for the ${planId} plan.`,
defaultLanguage: "en",
layout: "articles",
color: 500,
enabled: true,
languages: "en,de",
links: "[]",
logo: "",
seoImage: "",
},
update: {
tenantId,
title: `${tenantName} Knowledge Base`,
description: `Tenant-owned product-core demo knowledge base for the ${planId} plan.`,
enabled: true,
},
});
const category = await db.knowledgeBaseCategory.upsert({
where: { knowledgeBaseId_slug: { knowledgeBaseId: knowledgeBase.id, slug: "product-core" } },
create: {
knowledgeBaseId: knowledgeBase.id,
slug: "product-core",
order: 1,
title: demoKnowledgeBaseCategoryTitle,
description: demoKnowledgeBaseCategoryDescription,
icon: "book-open",
language: "en",
seoImage: "",
},
update: {
title: demoKnowledgeBaseCategoryTitle,
description: demoKnowledgeBaseCategoryDescription,
},
});
const content = `<p>${tenantName} is seeded on the ${planId} plan with CRM SaaS blueprint gates, tenant roles, support tickets, and tenant-owned knowledge base content.</p>`;
await db.knowledgeBaseArticle.upsert({
where: { knowledgeBaseId_slug: { knowledgeBaseId: knowledgeBase.id, slug: "plan-gates-overview" } },
create: {
knowledgeBaseId: knowledgeBase.id,
categoryId: category.id,
slug: "plan-gates-overview",
title: demoKnowledgeBaseArticleTitle,
description: `What the ${planId} plan enables in this seeded tenant.`,
order: 1,
contentDraft: content,
contentPublished: content,
contentPublishedAsText: `${tenantName} is seeded on the ${planId} plan with CRM SaaS blueprint gates, tenant roles, support tickets, and tenant-owned knowledge base content.`,
contentType: "html",
language: "en",
seoImage: "",
publishedAt: new Date(),
},
update: {
categoryId: category.id,
title: demoKnowledgeBaseArticleTitle,
description: `What the ${planId} plan enables in this seeded tenant.`,
contentDraft: content,
contentPublished: content,
contentPublishedAsText: `${tenantName} is seeded on the ${planId} plan with CRM SaaS blueprint gates, tenant roles, support tickets, and tenant-owned knowledge base content.`,
publishedAt: new Date(),
},
});
}
async function seedDemoSupportTickets(tenantId: string, tenantSlug: string, planId: EntitlementPlanId, userId?: string) {
const tickets = [
{
inboundMessageId: `seed:${tenantSlug}:welcome`,
subject: demoSupportTicketSubjects.welcome,
message: `This ${planId} tenant includes seeded support-ticket data so HelpDesk can be reviewed immediately.`,
priority: "normal",
status: "open",
},
{
inboundMessageId: `seed:${tenantSlug}:plan-gates`,
subject: demoSupportTicketSubjects.planGates,
message: demoSupportTicketPlanGatesMessage,
priority: planId === entitlementPlanIds.business ? "high" : "normal",
status: "open",
},
];
for (const ticket of tickets) {
await db.supportTicket.upsert({
where: { inboundMessageId: ticket.inboundMessageId },
create: {
tenantId,
userId,
source: "seed",
fromEmail: "support-demo@example.test",
fromUrl: `/${tenantSlug}/dashboard`,
...ticket,
},
update: {
tenantId,
userId,
source: "seed",
fromEmail: "support-demo@example.test",
fromUrl: `/${tenantSlug}/dashboard`,
subject: ticket.subject,
message: ticket.message,
priority: ticket.priority,
status: ticket.status,
},
});
}
}
export default {
seed,
};