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

616 lines
20 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 { getEntitlementPlanStripeId, seedStandardEntitlements, type EntitlementsSeedDiagnosticEvent } from "@toolctrl/db/entitlements-seed.server";
import { runDbSeed, type DbSeedDiagnosticEvent } from "@toolctrl/db/seed.server";
import { entitlementPlanIds, featureKeys, productBlueprintIds, type EntitlementPlanId } from "@toolctrl/entitlements";
import { seedRolesAndPermissions, type RolesAndPermissionsSeedDiagnosticEvent } from "~/utils/services/rolesAndPermissionsService";
import { db } from "~/utils/db.server";
import { ensureProductCoreCrmEntities } from "~/modules/crm/services/ProductCoreCrmEntities.server";
import { createScopedLogger } from "~/utils/logger.server";
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",
},
{
userType: "app" as const,
firstName: "Luna",
lastName: "Davis",
email: "luna.davis@company.com",
password: "password",
},
];
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 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.";
function getAdminSeedUser() {
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 adminUser = getAdminSeedUser();
const shouldSeedDemoData = shouldSeedCrmDemoData();
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", ...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)));
const [user1, user2] = demoUsers;
await Promise.all(
demoCrmTenants.map((tenant, index) =>
createTenant(tenant.slug, tenant.name, [
{ ...admin, type: TenantUserType.ADMIN },
{ ...user1, type: index === 0 ? TenantUserType.ADMIN : TenantUserType.OWNER },
{ ...user2, type: TenantUserType.MEMBER },
])
)
);
}
if (shouldSeedDemoData) {
await ensureProductCoreCrmEntities({ ensurePermissions: false });
}
// Permissions
await seedRolesAndPermissions({
adminEmail: adminUser.email,
diagnostics: logRolesAndPermissionsSeedDiagnostic,
});
await seedStandardEntitlements({
client: db,
diagnostics: logEntitlementsSeedDiagnostic,
});
if (shouldSeedDemoData) {
await Promise.all(demoCrmTenants.map((tenant) => assignTenantPlan(tenant.slug, tenant.planId)));
await Promise.all(demoCrmTenants.map((tenant) => seedDemoProductCoreTenant(tenant.slug, tenant.planId, demoUsers)));
}
},
});
}
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,
}: {
firstName: string;
lastName: string;
email: string;
password: string;
userType: "admin" | "app";
}) {
// 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) {
const isAdmin = await db.adminUser.findUnique({ where: { userId: 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: { userId: user.id } });
} 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: "",
},
});
if (userType === "admin") {
await db.adminUser.create({
data: { userId: user.id },
});
}
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,
stripeCustomerId: "",
},
});
}
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;
}
const product = await db.subscriptionProduct.findFirst({
where: { stripeId: getEntitlementPlanStripeId({ 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,
stripeSubscriptionId: 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),
]);
}
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,
};