From 328b4a1f7b33e0ec4c2e61e1955246837256e86c Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Mon, 30 Mar 2026 07:10:20 -0500 Subject: [PATCH] Fix dev seed for single-company schema, sync RBAC on startup - Remove all company_id references from dev-seed.ts (removed in 0021) - seedPermissions now syncs role-permission assignments for system roles when new permissions are added (e.g., vault.view assigned to admin) - Fix enum migration: use text cast workaround for PostgreSQL's "unsafe use of new enum value" error on fresh DB creation --- .../db/migrations/0019_repair_new_status.sql | 3 +- .../db/migrations/0020_repair_default_new.sql | 4 +- packages/backend/src/db/seeds/dev-seed.ts | 39 ++++++++----------- packages/backend/src/services/rbac.service.ts | 31 +++++++++++++-- 4 files changed, 50 insertions(+), 27 deletions(-) diff --git a/packages/backend/src/db/migrations/0019_repair_new_status.sql b/packages/backend/src/db/migrations/0019_repair_new_status.sql index 150af8d..e88db17 100644 --- a/packages/backend/src/db/migrations/0019_repair_new_status.sql +++ b/packages/backend/src/db/migrations/0019_repair_new_status.sql @@ -1 +1,2 @@ -ALTER TYPE "repair_ticket_status" ADD VALUE 'new' BEFORE 'in_transit'; +-- Must commit before using the new enum value +ALTER TYPE "repair_ticket_status" ADD VALUE IF NOT EXISTS 'new' BEFORE 'in_transit'; diff --git a/packages/backend/src/db/migrations/0020_repair_default_new.sql b/packages/backend/src/db/migrations/0020_repair_default_new.sql index e30312c..af5c57b 100644 --- a/packages/backend/src/db/migrations/0020_repair_default_new.sql +++ b/packages/backend/src/db/migrations/0020_repair_default_new.sql @@ -1 +1,3 @@ -ALTER TABLE "repair_ticket" ALTER COLUMN "status" SET DEFAULT 'new'; +-- Use text cast to avoid the "unsafe use of new enum value" error +-- when 0019 (ADD VALUE) and this run in the same session +ALTER TABLE "repair_ticket" ALTER COLUMN "status" SET DEFAULT 'new'::text::repair_ticket_status; diff --git a/packages/backend/src/db/seeds/dev-seed.ts b/packages/backend/src/db/seeds/dev-seed.ts index fbc5794..dd80366 100644 --- a/packages/backend/src/db/seeds/dev-seed.ts +++ b/packages/backend/src/db/seeds/dev-seed.ts @@ -18,7 +18,7 @@ async function seed() { const [company] = await sql`SELECT id FROM company WHERE id = ${COMPANY_ID}` if (!company) { await sql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Forte Music Store', 'America/Chicago')` - await sql`INSERT INTO location (id, company_id, name) VALUES ('a0000000-0000-0000-0000-000000000002', ${COMPANY_ID}, 'Main Store')` + await sql`INSERT INTO location (id, name) VALUES ('a0000000-0000-0000-0000-000000000002', 'Main Store')` console.log(' Created company and location') // Seed RBAC @@ -29,7 +29,7 @@ async function seed() { const permRows = await sql`SELECT id, slug FROM permission` const permMap = new Map(permRows.map((r: any) => [r.slug, r.id])) for (const roleDef of DEFAULT_ROLES) { - const [role] = await sql`INSERT INTO role (company_id, name, slug, description, is_system) VALUES (${COMPANY_ID}, ${roleDef.name}, ${roleDef.slug}, ${roleDef.description}, true) RETURNING id` + const [role] = await sql`INSERT INTO role (name, slug, description, is_system) VALUES (${roleDef.name}, ${roleDef.slug}, ${roleDef.description}, true) RETURNING id` for (const permSlug of roleDef.permissions) { const permId = permMap.get(permSlug) if (permId) await sql`INSERT INTO role_permission (role_id, permission_id) VALUES (${role.id}, ${permId}) ON CONFLICT DO NOTHING` @@ -39,23 +39,18 @@ async function seed() { } // --- Admin user (if not exists) --- - const [adminUser] = await sql`SELECT id, company_id FROM "user" WHERE email = 'admin@forte.dev'` + const [adminUser] = await sql`SELECT id FROM "user" WHERE email = 'admin@forte.dev'` if (!adminUser) { const bcrypt = await import('bcrypt') const hashedPw = await (bcrypt.default || bcrypt).hash('admin1234', 10) - const [user] = await sql`INSERT INTO "user" (company_id, email, password_hash, first_name, last_name, role) VALUES (${COMPANY_ID}, 'admin@forte.dev', ${hashedPw}, 'Admin', 'User', 'admin') RETURNING id` - const [adminRole] = await sql`SELECT id FROM role WHERE company_id = ${COMPANY_ID} AND slug = 'admin' LIMIT 1` + const [user] = await sql`INSERT INTO "user" (email, password_hash, first_name, last_name, role) VALUES ('admin@forte.dev', ${hashedPw}, 'Admin', 'User', 'admin') RETURNING id` + const [adminRole] = await sql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1` if (adminRole) { await sql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${user.id}, ${adminRole.id}) ON CONFLICT DO NOTHING` } console.log(' Created admin user: admin@forte.dev / admin1234') } else { - // Ensure admin is in the right company and has the admin role - if (adminUser.company_id !== COMPANY_ID) { - await sql`UPDATE "user" SET company_id = ${COMPANY_ID} WHERE id = ${adminUser.id}` - console.log(' Moved admin user to correct company') - } - const [adminRole] = await sql`SELECT id FROM role WHERE company_id = ${COMPANY_ID} AND slug = 'admin' LIMIT 1` + const [adminRole] = await sql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1` if (adminRole) { await sql`DELETE FROM user_role_assignment WHERE user_id = ${adminUser.id}` await sql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${adminUser.id}, ${adminRole.id}) ON CONFLICT DO NOTHING` @@ -76,13 +71,13 @@ async function seed() { const acctIds: Record = {} for (const a of accounts) { - const existing = await sql`SELECT id FROM account WHERE company_id = ${COMPANY_ID} AND name = ${a.name}` + const existing = await sql`SELECT id FROM account WHERE name = ${a.name}` if (existing.length > 0) { acctIds[a.name] = existing[0].id continue } const num = String(Math.floor(100000 + Math.random() * 900000)) - const [row] = await sql`INSERT INTO account (company_id, name, email, phone, account_number, billing_mode) VALUES (${COMPANY_ID}, ${a.name}, ${a.email}, ${a.phone}, ${num}, 'consolidated') RETURNING id` + const [row] = await sql`INSERT INTO account (name, email, phone, account_number, billing_mode) VALUES (${a.name}, ${a.email}, ${a.phone}, ${num}, 'consolidated') RETURNING id` acctIds[a.name] = row.id console.log(` Account: ${a.name}`) } @@ -101,10 +96,10 @@ async function seed() { for (const m of members) { const acctId = acctIds[m.accountName] - const existing = await sql`SELECT id FROM member WHERE company_id = ${COMPANY_ID} AND first_name = ${m.firstName} AND last_name = ${m.lastName}` + const existing = await sql`SELECT id FROM member WHERE first_name = ${m.firstName} AND last_name = ${m.lastName}` if (existing.length > 0) continue const num = String(Math.floor(100000 + Math.random() * 900000)) - await sql`INSERT INTO member (company_id, account_id, first_name, last_name, email, member_number, is_minor) VALUES (${COMPANY_ID}, ${acctId}, ${m.firstName}, ${m.lastName}, ${m.email ?? null}, ${num}, ${m.isMinor ?? false})` + await sql`INSERT INTO member (account_id, first_name, last_name, email, member_number, is_minor) VALUES (${acctId}, ${m.firstName}, ${m.lastName}, ${m.email ?? null}, ${num}, ${m.isMinor ?? false})` console.log(` Member: ${m.firstName} ${m.lastName}`) } @@ -129,9 +124,9 @@ async function seed() { ] for (const t of templates) { - const existing = await sql`SELECT id FROM repair_service_template WHERE company_id = ${COMPANY_ID} AND name = ${t.name} AND COALESCE(instrument_type, '') = ${t.instrumentType ?? ''} AND COALESCE(size, '') = ${t.size ?? ''}` + const existing = await sql`SELECT id FROM repair_service_template WHERE name = ${t.name} AND COALESCE(instrument_type, '') = ${t.instrumentType ?? ''} AND COALESCE(size, '') = ${t.size ?? ''}` if (existing.length > 0) continue - await sql`INSERT INTO repair_service_template (company_id, name, instrument_type, size, item_type, default_price, default_cost, is_active) VALUES (${COMPANY_ID}, ${t.name}, ${t.instrumentType}, ${t.size}, ${t.itemType}, ${t.price}, ${t.cost}, true)` + await sql`INSERT INTO repair_service_template (name, instrument_type, size, item_type, default_price, default_cost, is_active) VALUES (${t.name}, ${t.instrumentType}, ${t.size}, ${t.itemType}, ${t.price}, ${t.cost}, true)` console.log(` Template: ${t.name} ${t.instrumentType ?? ''} ${t.size ?? ''}`) } @@ -146,20 +141,20 @@ async function seed() { ] for (const t of tickets) { - const existing = await sql`SELECT id FROM repair_ticket WHERE company_id = ${COMPANY_ID} AND customer_name = ${t.customer} AND problem_description = ${t.problem}` + const existing = await sql`SELECT id FROM repair_ticket WHERE customer_name = ${t.customer} AND problem_description = ${t.problem}` if (existing.length > 0) continue const num = String(Math.floor(100000 + Math.random() * 900000)) const acctId = acctIds[t.customer] ?? null - await sql`INSERT INTO repair_ticket (company_id, ticket_number, customer_name, account_id, instrument_description, serial_number, problem_description, condition_in, status, estimated_cost) VALUES (${COMPANY_ID}, ${num}, ${t.customer}, ${acctId}, ${t.instrument}, ${t.serial}, ${t.problem}, ${t.condition}, ${t.status}, ${t.estimate})` + await sql`INSERT INTO repair_ticket (ticket_number, customer_name, account_id, instrument_description, serial_number, problem_description, condition_in, status, estimated_cost) VALUES (${num}, ${t.customer}, ${acctId}, ${t.instrument}, ${t.serial}, ${t.problem}, ${t.condition}, ${t.status}, ${t.estimate})` console.log(` Ticket: ${t.customer} — ${t.instrument} [${t.status}]`) } // --- Repair Batch --- - const batchExists = await sql`SELECT id FROM repair_batch WHERE company_id = ${COMPANY_ID} AND contact_name = 'Mr. Williams'` + const batchExists = await sql`SELECT id FROM repair_batch WHERE contact_name = 'Mr. Williams'` if (batchExists.length === 0) { const batchNum = String(Math.floor(100000 + Math.random() * 900000)) const schoolId = acctIds['Lincoln High School'] - const [batch] = await sql`INSERT INTO repair_batch (company_id, batch_number, account_id, contact_name, contact_phone, contact_email, instrument_count, notes, status) VALUES (${COMPANY_ID}, ${batchNum}, ${schoolId}, 'Mr. Williams', '555-0210', 'williams@lincoln.edu', 5, 'Annual band instrument checkup — 5 instruments', 'intake') RETURNING id` + const [batch] = await sql`INSERT INTO repair_batch (batch_number, account_id, contact_name, contact_phone, contact_email, instrument_count, notes, status) VALUES (${batchNum}, ${schoolId}, 'Mr. Williams', '555-0210', 'williams@lincoln.edu', 5, 'Annual band instrument checkup — 5 instruments', 'intake') RETURNING id` const batchTickets = [ { instrument: 'Student Flute', problem: 'Pads worn, needs replacement check', condition: 'fair' }, @@ -171,7 +166,7 @@ async function seed() { for (const bt of batchTickets) { const num = String(Math.floor(100000 + Math.random() * 900000)) - await sql`INSERT INTO repair_ticket (company_id, ticket_number, customer_name, account_id, repair_batch_id, instrument_description, problem_description, condition_in, status) VALUES (${COMPANY_ID}, ${num}, 'Lincoln High School', ${schoolId}, ${batch.id}, ${bt.instrument}, ${bt.problem}, ${bt.condition}, 'new')` + await sql`INSERT INTO repair_ticket (ticket_number, customer_name, account_id, repair_batch_id, instrument_description, problem_description, condition_in, status) VALUES (${num}, 'Lincoln High School', ${schoolId}, ${batch.id}, ${bt.instrument}, ${bt.problem}, ${bt.condition}, 'new')` console.log(` Batch ticket: ${bt.instrument}`) } console.log(` Batch: Lincoln High School — 5 instruments`) diff --git a/packages/backend/src/services/rbac.service.ts b/packages/backend/src/services/rbac.service.ts index c6c9eaf..dbcfe3b 100644 --- a/packages/backend/src/services/rbac.service.ts +++ b/packages/backend/src/services/rbac.service.ts @@ -7,15 +7,40 @@ import { ForbiddenError } from '../lib/errors.js' import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js' export const RbacService = { - /** Seed system permissions (global, run once) */ + /** Seed system permissions and sync role assignments for any new permissions */ async seedPermissions(db: PostgresJsDatabase) { const existing = await db.select({ slug: permissions.slug }).from(permissions) const existingSlugs = new Set(existing.map((p) => p.slug)) const toInsert = SYSTEM_PERMISSIONS.filter((p) => !existingSlugs.has(p.slug)) - if (toInsert.length === 0) return + if (toInsert.length > 0) { + await db.insert(permissions).values(toInsert) + } - await db.insert(permissions).values(toInsert) + // Sync system role permission assignments — ensures new permissions + // (e.g., vault.view) get assigned to existing roles on restart + const allPerms = await db.select().from(permissions) + const permMap = new Map(allPerms.map((p) => [p.slug, p.id])) + + const systemRoles = await db.select().from(roles).where(eq(roles.isSystem, true)) + for (const role of systemRoles) { + const roleDef = DEFAULT_ROLES.find((r) => r.slug === role.slug) + if (!roleDef) continue + + const existingAssignments = await db.select({ permissionId: rolePermissions.permissionId }) + .from(rolePermissions).where(eq(rolePermissions.roleId, role.id)) + const assignedPermIds = new Set(existingAssignments.map((a) => a.permissionId)) + + const missingPermIds = roleDef.permissions + .map((slug) => permMap.get(slug)) + .filter((id): id is string => id !== undefined && !assignedPermIds.has(id)) + + if (missingPermIds.length > 0) { + await db.insert(rolePermissions).values( + missingPermIds.map((permissionId) => ({ roleId: role.id, permissionId })), + ) + } + } }, /** Seed default roles */