import { db } from "@workspace/db"; import { BUILTIN_APP_SLUGS } from "@workspace/db/built-in-apps"; import { usersTable, rolesTable, userRolesTable, permissionsTable, rolePermissionsTable, appsTable, appPermissionsTable, serviceCategoriesTable, servicesTable, groupsTable, userGroupsTable, groupAppsTable, groupRolesTable, systemSettingsTable, executiveMeetingsTable, executiveMeetingAttendeesTable, executiveMeetingNotificationsTable, protocolRoomsTable, } from "@workspace/db"; import { eq, sql } from "drizzle-orm"; import bcrypt from "bcryptjs"; async function main() { console.log("Seeding database..."); // Create roles const [adminRole] = await db .insert(rolesTable) .values({ name: "admin", descriptionAr: "مدير النظام", descriptionEn: "System Administrator", isSystem: 1 }) .onConflictDoNothing() .returning(); const [userRole] = await db .insert(rolesTable) .values({ name: "user", descriptionAr: "مستخدم عادي", descriptionEn: "Regular User", isSystem: 1 }) .onConflictDoNothing() .returning(); console.log("Roles created"); // Create permissions const permissions = [ { name: "apps:manage", descriptionAr: "إدارة التطبيقات", descriptionEn: "Manage Apps" }, { name: "services:manage", descriptionAr: "إدارة الخدمات", descriptionEn: "Manage Services" }, { name: "users:manage", descriptionAr: "إدارة المستخدمين", descriptionEn: "Manage Users" }, { name: "orders.receive", descriptionAr: "استلام الطلبات", descriptionEn: "Receive service orders" }, ]; await db.insert(permissionsTable).values(permissions).onConflictDoNothing(); const allInsertedPerms = await db.select().from(permissionsTable); console.log("Permissions created"); // Create order_receiver role const [orderReceiverRole] = await db .insert(rolesTable) .values({ name: "order_receiver", descriptionAr: "مستلم الطلبات", descriptionEn: "Order Receiver", isSystem: 1, }) .onConflictDoNothing() .returning(); const allRoles = await db.select().from(rolesTable); const adminRoleResolved = adminRole ?? allRoles.find((r) => r.name === "admin"); const userRoleResolved = userRole ?? allRoles.find((r) => r.name === "user"); const orderReceiverResolved = orderReceiverRole ?? allRoles.find((r) => r.name === "order_receiver"); // Assign all permissions to admin role if (adminRoleResolved && allInsertedPerms.length > 0) { await db .insert(rolePermissionsTable) .values(allInsertedPerms.map((p) => ({ roleId: adminRoleResolved.id, permissionId: p.id }))) .onConflictDoNothing(); } // Assign orders:receive permission to order_receiver role if (orderReceiverResolved && allInsertedPerms.length > 0) { const ordersPerm = allInsertedPerms.find((p) => p.name === "orders.receive"); if (ordersPerm) { await db .insert(rolePermissionsTable) .values({ roleId: orderReceiverResolved.id, permissionId: ordersPerm.id }) .onConflictDoNothing(); } } // First-run install state. The canonical bootstrap + legacy backfill // live in the api-server startup hook (ensureSystemSettingsBootstrap), // so they run on every boot regardless of whether seed is invoked. // Here we only read the current flag to decide whether to create the // seeded admin/user pair below. const adminPassword = process.env.SEED_ADMIN_PASSWORD; const userPassword = process.env.SEED_USER_PASSWORD; await db .insert(systemSettingsTable) .values({ id: 1, installed: false }) .onConflictDoNothing(); const sysRows = await db .select() .from(systemSettingsTable) .where(eq(systemSettingsTable.id, 1)) .limit(1); const installedFlag = sysRows[0]?.installed ?? false; const existingAdminRows = await db .select({ id: usersTable.id }) .from(userRolesTable) .innerJoin(usersTable, eq(usersTable.id, userRolesTable.userId)) .innerJoin(rolesTable, eq(rolesTable.id, userRolesTable.roleId)) .where(eq(rolesTable.name, "admin")) .limit(1); const adminAlreadyExists = existingAdminRows.length > 0; let adminUser: { id: number } | undefined; let regularUser: { id: number } | undefined; const skipUserCreation = installedFlag || adminAlreadyExists; const haveSeedEnv = Boolean(adminPassword && userPassword); if (skipUserCreation) { console.log( "Skipping seeded admin/user — system already installed or an admin already exists.", ); } else if (!haveSeedEnv) { const host = process.env.PUBLIC_BASE_URL ?? "https://"; console.log( `[seed] No SEED_ADMIN_PASSWORD/SEED_USER_PASSWORD set — first-run wizard required at ${host}/setup`, ); } else { const adminHash = await bcrypt.hash(adminPassword!, 10); const insertedAdmin = await db .insert(usersTable) .values({ username: "admin", email: "admin@tx.local", passwordHash: adminHash, displayNameAr: "مدير النظام", displayNameEn: "System Admin", preferredLanguage: "ar", isActive: true, }) .onConflictDoNothing() .returning(); adminUser = insertedAdmin[0]; const userHash = await bcrypt.hash(userPassword!, 10); const insertedRegular = await db .insert(usersTable) .values({ username: "ahmed", email: "ahmed@tx.local", passwordHash: userHash, displayNameAr: "أحمد محمد", displayNameEn: "Ahmed Mohammed", preferredLanguage: "ar", isActive: true, }) .onConflictDoNothing() .returning(); regularUser = insertedRegular[0]; // Mark install complete so the wizard does not appear. The // bootstrap insert at the top of this script always wrote // (id=1, installed=false), so a plain onConflictDoNothing here // would leave installed=false. Use DO UPDATE to actually flip it. await db .insert(systemSettingsTable) .values({ id: 1, installed: true, installedAt: new Date() }) .onConflictDoUpdate({ target: systemSettingsTable.id, set: { installed: true, installedAt: sql`COALESCE(${systemSettingsTable.installedAt}, NOW())`, updatedAt: new Date(), }, }); console.log("Users created"); } // Assign roles const roles = await db.select().from(rolesTable); const adminRoleRow = roles.find((r) => r.name === "admin"); const userRoleRow = roles.find((r) => r.name === "user"); if (adminUser && adminRoleRow) { await db .insert(userRolesTable) .values({ userId: adminUser.id, roleId: adminRoleRow.id }) .onConflictDoNothing(); } if (regularUser && userRoleRow) { await db .insert(userRolesTable) .values({ userId: regularUser.id, roleId: userRoleRow.id }) .onConflictDoNothing(); } console.log("Roles assigned"); // Create apps const apps = [ { slug: "services", nameAr: "خدماتي", nameEn: "My Services", descriptionAr: "اطلب خدماتك الداخلية بسهولة", descriptionEn: "Order your internal services easily", iconName: "Coffee", route: "/services", color: "#f59e0b", isActive: true, isSystem: true, sortOrder: 1, }, { slug: "notifications", nameAr: "الإشعارات", nameEn: "Notifications", descriptionAr: "إدارة الإشعارات والتنبيهات", descriptionEn: "Manage your notifications and alerts", iconName: "Bell", route: "/notifications", // #603: was #8b5cf6 (purple). Switched to cyan so no seeded app // ships with a purple tile. Existing installs are migrated by // scripts/src/update-app-colors.ts (only rows still on the old // default are touched — admin customisations are preserved). color: "#06b6d4", // #571: ship the Notifications app DISABLED by default so fresh // installs don't surface a redundant tile alongside the bell icon // already in the top bar. The bell stays the canonical entry // point; admins can flip this on from app settings if they want // the launcher tile too. Insert here uses onConflictDoNothing, // so existing environments that already have the app enabled // are NOT affected — only new inserts pick up the default. isActive: false, isSystem: true, sortOrder: 3, }, { slug: "admin", nameAr: "لوحة التحكم", nameEn: "Admin Panel", descriptionAr: "إدارة النظام والمستخدمين", descriptionEn: "System and user management", iconName: "Settings", route: "/admin", color: "#ef4444", isActive: true, isSystem: true, sortOrder: 4, }, { slug: "notes", nameAr: "الملاحظات", nameEn: "Notes", descriptionAr: "ملاحظاتك الشخصية ورسائلك بين الزملاء", descriptionEn: "Personal notes and messages with colleagues", iconName: "StickyNote", route: "/notes", color: "#eab308", isActive: true, isSystem: false, sortOrder: 8, }, { slug: "calendar", nameAr: "التقويم", nameEn: "Calendar", descriptionAr: "الاجتماعات والمواعيد", descriptionEn: "Meetings and appointments", iconName: "Calendar", route: "/calendar", color: "#3b82f6", // #571: ship the Calendar app DISABLED by default. Admins can // enable it from app settings when they actually want a // calendar surface. Insert uses onConflictDoNothing so existing // environments where calendar is already enabled stay enabled. isActive: false, isSystem: false, sortOrder: 5, }, { slug: "documents", nameAr: "المستندات", nameEn: "Documents", descriptionAr: "مستودع المستندات والملفات", descriptionEn: "Documents and files repository", iconName: "FileText", route: "/documents", color: "#64748b", // #577: same policy as notifications/calendar — ship Documents // DISABLED on fresh installs. There is no Documents surface the // user is actively using yet and the tile cluttered the home // launcher. Insert uses onConflictDoNothing, so existing // environments keep whatever the current row says — see // scripts/src/disable-deprecated-apps.ts for the one-shot that // flips already-seeded rows off. isActive: false, isSystem: false, sortOrder: 6, }, { slug: "executive-meetings", nameAr: "الاجتماعات", nameEn: "Meetings", descriptionAr: "جدولة الاجتماعات ومتابعتها", descriptionEn: "Schedule and track meetings", iconName: "CalendarClock", route: "/meetings", // #603: was #0B1E3F (dark navy) which fell through to the default // blue tile gradient, making Meetings indistinguishable from // Admin/Notes on the home screen. Switched to green so the four // default tiles are visually distinct. Existing installs are // migrated by scripts/src/update-app-colors.ts. color: "#22c55e", isActive: true, isSystem: false, sortOrder: 7, }, { slug: "protocol", nameAr: "العلاقات العامة والمراسم", nameEn: "Public Relations & Protocol", descriptionAr: "حجز القاعات والاجتماعات الخارجية والهدايا والدروع", descriptionEn: "Room bookings, external meetings, gifts and shields", iconName: "Handshake", route: "/protocol", color: "#0ea5e9", isActive: true, isSystem: false, sortOrder: 9, }, ]; // Drift guard: every built-in slug (those with hardcoded routes in the // SPA, see lib/db/src/built-in-apps.ts) that this seed actually creates // here must use the same `route` the SPA expects. We don't require ALL // built-in slugs to be seeded (some, like "notes" / "my-orders" / // "orders-incoming", are user-created later), but if we DO seed one, // the route must match the hardcoded SPA route. const expectedBuiltinRoutes: Record = { services: "/services", notifications: "/notifications", admin: "/admin", notes: "/notes", "my-orders": "/my-orders", "orders-incoming": "/orders/incoming", "executive-meetings": "/meetings", calendar: "/calendar", documents: "/documents", protocol: "/protocol", }; for (const a of apps) { if ((BUILTIN_APP_SLUGS as readonly string[]).includes(a.slug)) { const expected = expectedBuiltinRoutes[a.slug]; if (expected && a.route !== expected) { throw new Error( `seed: built-in app "${a.slug}" must use route "${expected}" (got "${a.route}"). ` + `Built-in slugs are listed in lib/db/src/built-in-apps.ts and have hardcoded SPA routes in artifacts/tx-os/src/App.tsx.`, ); } } } const insertedApps = await db.insert(appsTable).values(apps).onConflictDoNothing().returning(); console.log("Apps created"); // Force the executive-meetings row onto the canonical /meetings route // AND onto the current display name. The slug is in BUILTIN_APP_SLUGS, // so the SPA owns its route — any drift in the DB (including legacy // /executive-meetings or other experimental values) would launch the // home tile at a path that has no React route and silently break the // app. `onConflictDoNothing` above skips updating existing rows, so // this UPDATE is what carries the route + name migration into // already-deployed environments. The WHERE by slug keeps it // idempotent across re-seeds. Names are kept in sync with the // hardcoded values in the `apps` array above so admins on existing // deployments don't have to rename the tile by hand. await db .update(appsTable) .set({ route: "/meetings", nameAr: "الاجتماعات", nameEn: "Meetings", descriptionAr: "جدولة الاجتماعات ومتابعتها", descriptionEn: "Schedule and track meetings", }) .where(eq(appsTable.slug, "executive-meetings")); // Restrict admin app to users with apps:manage permission const allPerms = await db.select().from(permissionsTable); const appsManagePerm = allPerms.find((p) => p.name === "apps:manage"); const adminApp = insertedApps.find((a) => a.slug === "admin") ?? (await db.select().from(appsTable).where(eq(appsTable.slug, "admin")))[0]; if (adminApp && appsManagePerm) { await db .insert(appPermissionsTable) .values({ appId: adminApp.id, permissionId: appsManagePerm.id }) .onConflictDoNothing(); console.log("App permissions set: admin app restricted to apps:manage permission"); } // Create service categories const categories = [ { nameAr: "مشروبات", nameEn: "Beverages", sortOrder: 1 }, { nameAr: "أغذية", nameEn: "Food", sortOrder: 2 }, { nameAr: "أخرى", nameEn: "Other", sortOrder: 3 }, ]; const insertedCategories = await db .insert(serviceCategoriesTable) .values(categories) .onConflictDoNothing() .returning(); const beveragesCat = insertedCategories[0]; // ----- Services: idempotent renames for legacy rows ----- // Older deployments seeded "Arabic Coffee" / "قهوة عربية" and // "قهوة بلاك". Rename them BEFORE the insert below so the insert's // onConflictDoNothing (target: nameEn) sees the new "Saudi Coffee" // name already present and skips it — avoiding a unique-constraint // collision on services.name_en. Never overwrite an imageUrl that // an admin has already customized. const existingServices = await db.select().from(servicesTable); const legacyArabicCoffee = existingServices.find( (s) => s.nameEn === "Arabic Coffee", ); if (legacyArabicCoffee) { await db .update(servicesTable) .set({ nameAr: "قهوة سعودي", nameEn: "Saudi Coffee", imageUrl: legacyArabicCoffee.imageUrl && legacyArabicCoffee.imageUrl !== "service-images/arabic-coffee.png" ? legacyArabicCoffee.imageUrl : "service-images/saudi-coffee.png", }) .where(eq(servicesTable.id, legacyArabicCoffee.id)); } const legacyBlackCoffee = existingServices.find( (s) => s.nameEn === "Black Coffee" && s.nameAr === "قهوة بلاك", ); if (legacyBlackCoffee) { await db .update(servicesTable) .set({ nameAr: "بلاك كوفي" }) .where(eq(servicesTable.id, legacyBlackCoffee.id)); } // Create services const services = [ { categoryId: beveragesCat?.id ?? null, nameAr: "شاي", nameEn: "Tea", imageUrl: "service-images/tea.png", price: "0.00", isAvailable: true, sortOrder: 1, }, { categoryId: beveragesCat?.id ?? null, nameAr: "قهوة سعودي", nameEn: "Saudi Coffee", imageUrl: "service-images/saudi-coffee.png", price: "0.00", isAvailable: true, sortOrder: 2, }, { categoryId: beveragesCat?.id ?? null, nameAr: "بلاك كوفي", nameEn: "Black Coffee", imageUrl: "service-images/black-coffee.png", price: "0.00", isAvailable: true, sortOrder: 3, }, { categoryId: beveragesCat?.id ?? null, nameAr: "عصير طازج", nameEn: "Fresh Juice", imageUrl: "service-images/juice.png", price: "5.00", isAvailable: true, sortOrder: 5, }, ]; await db.insert(servicesTable).values(services).onConflictDoNothing({ target: servicesTable.nameEn }); console.log("Services created"); // Backfill default imageUrl on existing rows that have a NULL imageUrl // (the insert above is onConflictDoNothing, so existing rows aren't // touched). Only fills NULL — admin-customized URLs are never // overwritten. Re-reads the table so newly-inserted rows are included. const allServicesAfterInsert = await db.select().from(servicesTable); for (const seed of services) { if (!seed.imageUrl) continue; const existing = allServicesAfterInsert.find((s) => s.nameEn === seed.nameEn); if (existing && !existing.imageUrl) { await db .update(servicesTable) .set({ imageUrl: seed.imageUrl }) .where(eq(servicesTable.id, existing.id)); } } console.log("Service images backfilled for NULL rows"); // ----- Groups: idempotent migration ----- await db .insert(groupsTable) .values([ { name: "Admins", descriptionAr: "مديرو النظام", descriptionEn: "System administrators", isSystem: 1, }, { name: "Tx", descriptionAr: "فريق إعداد وتقديم الطلبات", descriptionEn: "Team that prepares and delivers orders", isSystem: 1, }, { name: "Everyone", descriptionAr: "كل المستخدمين", descriptionEn: "All users", isSystem: 1, }, ]) .onConflictDoNothing(); // One-time migration: rename legacy "TeaBoy" group to "Tx" if it still exists // and the new "Tx" group hasn't been created yet. const existingGroups = await db.select().from(groupsTable); const legacy = existingGroups.find((g) => g.name === "TeaBoy"); const newOne = existingGroups.find((g) => g.name === "Tx"); if (legacy && !newOne) { await db .update(groupsTable) .set({ name: "Tx" }) .where(eq(groupsTable.id, legacy.id)); } const allGroups = await db.select().from(groupsTable); const adminsGroup = allGroups.find((g) => g.name === "Admins"); const txGroup = allGroups.find((g) => g.name === "Tx"); const everyoneGroup = allGroups.find((g) => g.name === "Everyone"); const allRolesNow = await db.select().from(rolesTable); const adminRoleNow = allRolesNow.find((r) => r.name === "admin"); const orderReceiverNow = allRolesNow.find((r) => r.name === "order_receiver"); // Group → roles if (adminsGroup && adminRoleNow) { await db .insert(groupRolesTable) .values({ groupId: adminsGroup.id, roleId: adminRoleNow.id }) .onConflictDoNothing(); } if (txGroup && orderReceiverNow) { await db .insert(groupRolesTable) .values({ groupId: txGroup.id, roleId: orderReceiverNow.id }) .onConflictDoNothing(); } // Group → apps const allAppsNow = await db.select().from(appsTable); if (adminsGroup) { await db .insert(groupAppsTable) .values(allAppsNow.map((a) => ({ groupId: adminsGroup.id, appId: a.id }))) .onConflictDoNothing(); } // The Tx receivers group gets the services app (where receivers see incoming orders) if (txGroup) { const servicesApp = allAppsNow.find((a) => a.slug === "services"); if (servicesApp) { await db .insert(groupAppsTable) .values({ groupId: txGroup.id, appId: servicesApp.id }) .onConflictDoNothing(); } } // Map existing users to groups idempotently const allUsersNow = await db.select({ id: usersTable.id }).from(usersTable); if (everyoneGroup && allUsersNow.length > 0) { await db .insert(userGroupsTable) .values(allUsersNow.map((u) => ({ userId: u.id, groupId: everyoneGroup.id }))) .onConflictDoNothing(); } if (adminsGroup && adminRoleNow) { const adminUserRows = await db .select({ userId: userRolesTable.userId }) .from(userRolesTable) .where(eq(userRolesTable.roleId, adminRoleNow.id)); if (adminUserRows.length > 0) { await db .insert(userGroupsTable) .values(adminUserRows.map((r) => ({ userId: r.userId, groupId: adminsGroup.id }))) .onConflictDoNothing(); } } if (txGroup && orderReceiverNow) { const receiverRows = await db .select({ userId: userRolesTable.userId }) .from(userRolesTable) .where(eq(userRolesTable.roleId, orderReceiverNow.id)); if (receiverRows.length > 0) { await db .insert(userGroupsTable) .values(receiverRows.map((r) => ({ userId: r.userId, groupId: txGroup.id }))) .onConflictDoNothing(); } } console.log("Groups created and existing users mapped"); // Executive meetings: roles const execRoles = [ { name: "executive_ceo", descriptionAr: "الرئيس التنفيذي", descriptionEn: "Chief Executive Officer", isSystem: 1, }, { name: "executive_office_manager", descriptionAr: "مدير المكتب التنفيذي", descriptionEn: "Executive Office Manager", isSystem: 1, }, { name: "executive_coord_lead", descriptionAr: "رئيس التنسيق والتوثيق", descriptionEn: "Coordination Lead", isSystem: 1, }, { name: "executive_coordinator", descriptionAr: "منسق المكتب التنفيذي", descriptionEn: "Executive Coordinator", isSystem: 1, }, { name: "executive_viewer", descriptionAr: "مشاهد الاجتماعات التنفيذية", descriptionEn: "Executive Meetings Viewer", isSystem: 1, }, ]; await db.insert(rolesTable).values(execRoles).onConflictDoNothing(); console.log("Executive Meetings roles created"); // Executive meetings: gate the home-screen icon to executive roles only. // This adds a single permission, grants it to admin + the 5 executive // roles, and links it to the `executive-meetings` app via app_permissions // so getVisibleAppsForUser will hide the icon for everyone else. // All three inserts are idempotent. await db .insert(permissionsTable) .values({ name: "executive_meetings.access", descriptionAr: "الوصول إلى وحدة الاجتماعات التنفيذية", descriptionEn: "Access the Executive Meetings module", }) .onConflictDoNothing(); const [execAccessPerm] = await db .select() .from(permissionsTable) .where(eq(permissionsTable.name, "executive_meetings.access")); if (execAccessPerm) { const execRoleNames = [ "admin", "executive_ceo", "executive_office_manager", "executive_coord_lead", "executive_coordinator", "executive_viewer", ]; const allRolesNow = await db.select().from(rolesTable); const execRoleRows = allRolesNow.filter((r) => execRoleNames.includes(r.name)); if (execRoleRows.length > 0) { await db .insert(rolePermissionsTable) .values( execRoleRows.map((r) => ({ roleId: r.id, permissionId: execAccessPerm.id, })), ) .onConflictDoNothing(); } const [execApp] = await db .select() .from(appsTable) .where(eq(appsTable.slug, "executive-meetings")); if (execApp) { await db .insert(appPermissionsTable) .values({ appId: execApp.id, permissionId: execAccessPerm.id }) .onConflictDoNothing(); console.log( "App permissions set: executive-meetings app restricted to executive_meetings.access permission", ); } } // Notes: gate the home-screen icon behind a dedicated permission so a // freshly seeded deployment does NOT auto-show Notes to every regular // user. By default only the `admin` role gets the permission; admins // then grant `notes.access` to whichever roles/users they want from // the admin panel. Same three-step pattern as executive_meetings.access // above — all inserts are idempotent. await db .insert(permissionsTable) .values({ name: "notes.access", descriptionAr: "الوصول إلى تطبيق الملاحظات", descriptionEn: "Access the Notes app", }) .onConflictDoNothing(); const [notesAccessPerm] = await db .select() .from(permissionsTable) .where(eq(permissionsTable.name, "notes.access")); if (notesAccessPerm) { const adminRoleForNotes = (await db.select().from(rolesTable)) .find((r) => r.name === "admin"); if (adminRoleForNotes) { await db .insert(rolePermissionsTable) .values({ roleId: adminRoleForNotes.id, permissionId: notesAccessPerm.id, }) .onConflictDoNothing(); } const [notesApp] = await db .select() .from(appsTable) .where(eq(appsTable.slug, "notes")); if (notesApp) { await db .insert(appPermissionsTable) .values({ appId: notesApp.id, permissionId: notesAccessPerm.id }) .onConflictDoNothing(); console.log( "App permissions set: notes app restricted to notes.access permission", ); } } // Public Relations & Protocol: roles. const protocolRoles = [ { name: "protocol_super_admin", descriptionAr: "المدير العام للعلاقات العامة والمراسم", descriptionEn: "Protocol Super Admin", isSystem: 1, }, { name: "protocol_pr_manager", descriptionAr: "مدير العلاقات العامة", descriptionEn: "PR Manager", isSystem: 1, }, { name: "protocol_officer", descriptionAr: "موظف المراسم", descriptionEn: "Protocol Officer", isSystem: 1, }, { name: "protocol_requester", descriptionAr: "مقدم الطلب", descriptionEn: "Requester", isSystem: 1, }, { name: "protocol_viewer", descriptionAr: "مشاهد العلاقات العامة والمراسم", descriptionEn: "Protocol Viewer", isSystem: 1, }, ]; await db.insert(rolesTable).values(protocolRoles).onConflictDoNothing(); console.log("Protocol roles created"); // Gate the Protocol home-screen icon to admin + the 5 protocol roles. await db .insert(permissionsTable) .values({ name: "protocol.access", descriptionAr: "الوصول إلى وحدة العلاقات العامة والمراسم", descriptionEn: "Access the Public Relations & Protocol module", }) .onConflictDoNothing(); const [protocolAccessPerm] = await db .select() .from(permissionsTable) .where(eq(permissionsTable.name, "protocol.access")); if (protocolAccessPerm) { const protocolRoleNames = [ "admin", "protocol_super_admin", "protocol_pr_manager", "protocol_officer", "protocol_requester", "protocol_viewer", ]; const allRolesForProtocol = await db.select().from(rolesTable); const protocolRoleRows = allRolesForProtocol.filter((r) => protocolRoleNames.includes(r.name), ); if (protocolRoleRows.length > 0) { await db .insert(rolePermissionsTable) .values( protocolRoleRows.map((r) => ({ roleId: r.id, permissionId: protocolAccessPerm.id, })), ) .onConflictDoNothing(); } const [protocolApp] = await db .select() .from(appsTable) .where(eq(appsTable.slug, "protocol")); if (protocolApp) { await db .insert(appPermissionsTable) .values({ appId: protocolApp.id, permissionId: protocolAccessPerm.id }) .onConflictDoNothing(); console.log( "App permissions set: protocol app restricted to protocol.access permission", ); } } // Public Relations & Protocol: three default meeting rooms. Only seeded // when the table is empty so admins can rename/remove them freely without // re-seeds bringing them back. const existingRooms = await db .select({ id: protocolRoomsTable.id }) .from(protocolRoomsTable) .limit(1); if (existingRooms.length === 0) { await db.insert(protocolRoomsTable).values([ { nameAr: "قاعة الاجتماعات الرئيسية", nameEn: "Main Meeting Hall", sortOrder: 1, }, { nameAr: "قاعة كبار الزوار", nameEn: "VIP Guests Hall", sortOrder: 2, }, { nameAr: "قاعة الاجتماعات التنفيذية", nameEn: "Executive Meetings Hall", sortOrder: 3, }, ]); console.log("Protocol default rooms created"); } // Executive meetings: sample data for today (idempotent per-date). // // Opt-in only — a vanilla self-hosted install should start with an // empty Executive Meetings module. Set `SEED_DEMO_MEETINGS=true` in // your environment when bootstrapping a demo / staging deploy that // benefits from realistic-looking sample data. const seedDemoMeetings = (process.env.SEED_DEMO_MEETINGS ?? "").toLowerCase() === "true"; const today = new Date().toISOString().slice(0, 10); const existingForToday = seedDemoMeetings ? await db .select({ id: executiveMeetingsTable.id }) .from(executiveMeetingsTable) .where(eq(executiveMeetingsTable.meetingDate, today)) : []; if (!seedDemoMeetings) { console.log( "Executive Meetings demo data skipped (set SEED_DEMO_MEETINGS=true to enable)", ); } else if (existingForToday.length === 0) { { const sampleMeetings: Array<{ meeting: { dailyNumber: number; titleAr: string; titleEn: string; meetingDate: string; startTime: string; endTime: string; platform: "none" | "webex" | "teams" | "zoom" | "other"; isHighlighted: number; status: string; }; attendees: Array<{ name: string; attendanceType: "internal" | "virtual" | "external"; }>; }> = [ { meeting: { dailyNumber: 1, titleAr: "اجتماع افتتاحي للمكتب التنفيذي", titleEn: "Executive Office Opening Meeting", meetingDate: today, startTime: "10:00", endTime: "10:30", platform: "none", isHighlighted: 0, status: "scheduled", }, attendees: [ { name: "أ. سعد العتيبي", attendanceType: "internal" }, { name: "أ. خالد الشهري", attendanceType: "internal" }, ], }, { meeting: { dailyNumber: 2, titleAr: "اجتماع بشأن خطة التحول الرقمي", titleEn: "Digital Transformation Plan Meeting", meetingDate: today, startTime: "11:00", endTime: "11:30", platform: "webex", isHighlighted: 0, status: "scheduled", }, attendees: [ { name: "د. محمد الزهراني", attendanceType: "virtual" }, { name: "أ. عبدالعزيز القحطاني", attendanceType: "virtual" }, { name: "أ. ناصر الدوسري", attendanceType: "virtual" }, { name: "م. فهد المالكي", attendanceType: "virtual" }, ], }, { meeting: { dailyNumber: 3, titleAr: "اجتماع تحضيري لاجتماع مجلس الإدارة", titleEn: "Board Meeting Preparation", meetingDate: today, startTime: "11:40", endTime: "12:10", platform: "none", isHighlighted: 0, status: "scheduled", }, attendees: [ { name: "أ. تركي السبيعي", attendanceType: "internal" }, { name: "أ. ماجد الحربي", attendanceType: "internal" }, { name: "أ. عبدالله البقمي", attendanceType: "internal" }, { name: "أ. سلطان الغامدي", attendanceType: "internal" }, ], }, { meeting: { dailyNumber: 4, titleAr: "اجتماع بشأن عرض الخطة الخاصة بالقطاعين", titleEn: "Two-Sector Plan Presentation", meetingDate: today, startTime: "13:00", endTime: "13:30", platform: "none", isHighlighted: 0, status: "scheduled", }, attendees: [ { name: "أ. بدر العنزي", attendanceType: "internal" }, { name: "أ. هاني المطيري", attendanceType: "internal" }, { name: "أ. وليد الرشيدي", attendanceType: "internal" }, { name: "أ. يوسف الجهني", attendanceType: "internal" }, { name: "أ. عبدالرحمن الشمري", attendanceType: "internal" }, ], }, { meeting: { dailyNumber: 5, titleAr: "اجتماع مع أ. عبدالله بشأن المتابعة", titleEn: "Follow-up Meeting with Mr. Abdullah", meetingDate: today, startTime: "13:30", endTime: "13:50", platform: "none", isHighlighted: 0, status: "scheduled", }, attendees: [{ name: "أ. عبدالله الزهراني", attendanceType: "internal" }], }, { meeting: { dailyNumber: 6, titleAr: "اجتماع دوري بشأن مواضيع مكتب الإدارة", titleEn: "Periodic Office Topics Meeting", meetingDate: today, startTime: "14:00", endTime: "14:30", platform: "none", isHighlighted: 0, status: "scheduled", }, attendees: [ { name: "أ. مازن الفيفي", attendanceType: "internal" }, { name: "أ. سامي العسيري", attendanceType: "internal" }, { name: "أ. أحمد البلوي", attendanceType: "internal" }, ], }, { meeting: { dailyNumber: 7, titleAr: "اجتماع بشأن إطار اعتماد البرامج", titleEn: "Program Accreditation Framework", meetingDate: today, startTime: "14:40", endTime: "15:10", platform: "none", isHighlighted: 0, status: "scheduled", }, attendees: [ { name: "د. عبدالمجيد الحارثي", attendanceType: "internal" }, { name: "د. منصور القرني", attendanceType: "internal" }, { name: "أ. فيصل الزهراني", attendanceType: "internal" }, ], }, { meeting: { dailyNumber: 8, titleAr: "اجتماع بشأن استراتيجية الموارد البشرية", titleEn: "HR Strategy Meeting", meetingDate: today, startTime: "15:30", endTime: "16:30", platform: "webex", isHighlighted: 1, status: "scheduled", }, attendees: [ { name: "د. سعود الشهراني", attendanceType: "virtual" }, { name: "أ. عبدالإله الحربي", attendanceType: "virtual" }, { name: "أ. خالد العنزي", attendanceType: "virtual" }, { name: "أ. أحمد المالكي", attendanceType: "internal" }, { name: "أ. ناصر القحطاني", attendanceType: "internal" }, { name: "أ. سعيد الغامدي", attendanceType: "internal" }, { name: "أ. علي الزهراني", attendanceType: "internal" }, { name: "أ. محمد البقمي", attendanceType: "internal" }, ], }, { meeting: { dailyNumber: 9, titleAr: "اجتماع بشأن متابعة المشاريع المشتركة", titleEn: "Joint Projects Follow-up", meetingDate: today, startTime: "16:40", endTime: "17:10", platform: "webex", isHighlighted: 0, status: "scheduled", }, attendees: [ { name: "أ. يزيد العتيبي", attendanceType: "virtual" }, { name: "أ. تركي السهلي", attendanceType: "virtual" }, { name: "أ. عبدالله الزيد", attendanceType: "internal" }, ], }, ]; const insertedMeetings: number[] = []; for (const { meeting, attendees } of sampleMeetings) { const [inserted] = await db .insert(executiveMeetingsTable) .values(meeting) .returning(); if (!inserted) continue; insertedMeetings.push(inserted.id); await db.insert(executiveMeetingAttendeesTable).values( attendees.map((a, idx) => ({ meetingId: inserted.id, name: a.name, attendanceType: a.attendanceType, sortOrder: idx, })), ); } console.log("Executive Meetings sample data created for today"); // Seed example notification rules so the Notifications tab has // representative content (one row per type/status combination so // the page never appears empty in a fresh install). if (insertedMeetings.length > 0) { const adminId = ( await db .select({ id: usersTable.id }) .from(usersTable) .where(eq(usersTable.username, "admin")) )[0]?.id ?? null; const now = new Date(); const minutes = (m: number) => new Date(now.getTime() + m * 60 * 1000); const samples: Array<{ meetingId: number; userId: number | null; notificationType: string; scheduledAt: Date | null; sentAt: Date | null; status: string; }> = [ { meetingId: insertedMeetings[0]!, userId: adminId, notificationType: "meeting_reminder_15m", scheduledAt: minutes(15), sentAt: null, status: "pending", }, { meetingId: insertedMeetings[0]!, userId: adminId, notificationType: "meeting_reminder_60m", scheduledAt: minutes(60), sentAt: null, status: "pending", }, ]; if (insertedMeetings[1]) { samples.push({ meetingId: insertedMeetings[1]!, userId: adminId, notificationType: "meeting_invite", scheduledAt: minutes(-30), sentAt: minutes(-25), status: "sent", }); } await db.insert(executiveMeetingNotificationsTable).values(samples); console.log("Executive Meetings notification samples created"); } } } else { console.log("Executive Meetings sample data already exists for today, skipping"); } console.log("\n✅ Seeding complete!"); console.log("\nDemo accounts seeded (passwords from env, not logged):"); console.log(" Admin: username=admin"); console.log(" User: username=ahmed"); process.exit(0); } main().catch((err) => { console.error(err); process.exit(1); });