973 lines
34 KiB
TypeScript
973 lines
34 KiB
TypeScript
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,
|
||
};
|