5e460920c7
Addresses the 8 spec gaps that blocked prior completion, all additive and scoped to the protocol_* module / /protocol routes. executive-meetings untouched. Backend (api-server/routes/protocol.ts, lib/db schema, seed): - External meetings create as "pending" (status dropped from create schema); added approve + reject endpoints with state-conditional WHERE status='pending' and 409 on contention; patch status enum restricted to completed/cancelled. - Added approvedBy/approvedAt/rejectionReason columns + status index; default status now "pending". - Booking reject and gift-issue reject made state-conditional (409 on 0 rows). - Dashboard: roomsAvailableNow (NOT EXISTS), giftsIssuedThisMonth/Qty; upcomingExternal filtered to future pending|approved. - Reports: added externalMeetings summary counts by status. - Seed: room names corrected (AR + EN). Frontend (tx-os/pages/protocol.tsx, App.tsx, locales): - URL-synced tabs under /protocol/:tab (slug<->tab maps). - Dedicated Rooms tab (moved out of Gifts); RoomDialog isActive toggle. - External approve/reject buttons + rejectionReason display; ExternalDialog create omits status, edit limited to completed/cancelled. - Dashboard cards clickable to navigate; reports external section. - Bookings list/calendar (grouped-by-day) view toggle. - New i18n keys in ar.json + en.json. Verification: tx-os + protocol.ts typecheck clean; drizzle push + re-seed done; both workflows restarted; /api/protocol/me returns 401 unauth; architect re-review approved. Pre-existing typecheck errors in executive-meetings.ts and push.ts are unrelated and were not touched. Note: had to rebuild lib/db declarations (tsc --build --force) so api-server project references picked up the new schema columns.
1194 lines
40 KiB
TypeScript
1194 lines
40 KiB
TypeScript
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://<host>";
|
|
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<string, string> = {
|
|
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);
|
|
});
|