From f743169354f19b5e76a5fbec26f94678ac202b00 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 24 Apr 2026 12:30:06 +0200 Subject: [PATCH] feat(permissions): add yacht, company, membership, reservation keys --- src/lib/db/schema/users.ts | 58 +++- src/lib/db/seed.ts | 313 ++++++++++++++++++-- tests/helpers/factories.ts | 16 + tests/integration/permission-matrix.test.ts | 75 ++++- 4 files changed, 414 insertions(+), 48 deletions(-) diff --git a/src/lib/db/schema/users.ts b/src/lib/db/schema/users.ts index 15ddca5..4d93843 100644 --- a/src/lib/db/schema/users.ts +++ b/src/lib/db/schema/users.ts @@ -1,12 +1,4 @@ -import { - pgTable, - text, - boolean, - timestamp, - jsonb, - index, - uniqueIndex, -} from 'drizzle-orm/pg-core'; +import { pgTable, text, boolean, timestamp, jsonb, index, uniqueIndex } from 'drizzle-orm/pg-core'; import { ports } from './ports'; // ─── Permission Types ───────────────────────────────────────────────────────── @@ -92,6 +84,29 @@ export type RolePermissions = { generate: boolean; manage: boolean; }; + yachts: { + view: boolean; + create: boolean; + edit: boolean; + delete: boolean; + transfer: boolean; + }; + companies: { + view: boolean; + create: boolean; + edit: boolean; + delete: boolean; + }; + memberships: { + view: boolean; + manage: boolean; + }; + reservations: { + view: boolean; + create: boolean; + activate: boolean; + cancel: boolean; + }; admin: { manage_users: boolean; view_audit_log: boolean; @@ -132,7 +147,9 @@ export const account = pgTable('account', { id: text('id').primaryKey(), accountId: text('account_id').notNull(), providerId: text('provider_id').notNull(), - userId: text('user_id').notNull().references(() => user.id), + userId: text('user_id') + .notNull() + .references(() => user.id), accessToken: text('access_token'), refreshToken: text('refresh_token'), idToken: text('id_token'), @@ -163,7 +180,9 @@ export const verification = pgTable('verification', { export const userProfiles = pgTable( 'user_profiles', { - id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), userId: text('user_id').notNull().unique(), // references Better Auth user ID displayName: text('display_name').notNull(), avatarUrl: text('avatar_url'), @@ -179,10 +198,15 @@ export const userProfiles = pgTable( ); export const roles = pgTable('roles', { - id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), name: text('name').notNull(), description: text('description'), - permissions: jsonb('permissions').$type().notNull().default({} as RolePermissions), + permissions: jsonb('permissions') + .$type() + .notNull() + .default({} as RolePermissions), isGlobal: boolean('is_global').notNull().default(true), isSystem: boolean('is_system').notNull().default(false), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), @@ -192,7 +216,9 @@ export const roles = pgTable('roles', { export const portRoleOverrides = pgTable( 'port_role_overrides', { - id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), portId: text('port_id') .notNull() .references(() => ports.id, { onDelete: 'cascade' }), @@ -215,7 +241,9 @@ export const portRoleOverrides = pgTable( export const userPortRoles = pgTable( 'user_port_roles', { - id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), userId: text('user_id').notNull(), // references Better Auth user ID portId: text('port_id') .notNull() diff --git a/src/lib/db/seed.ts b/src/lib/db/seed.ts index 4e9ec84..63808fb 100644 --- a/src/lib/db/seed.ts +++ b/src/lib/db/seed.ts @@ -19,82 +19,332 @@ import type { RolePermissions } from './schema/users'; const ALL_PERMISSIONS: RolePermissions = { clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, - interests: { view: true, create: true, edit: true, delete: true, change_stage: true, generate_eoi: true, export: true }, + interests: { + view: true, + create: true, + edit: true, + delete: true, + change_stage: true, + generate_eoi: true, + export: true, + }, berths: { view: true, edit: true, import: true, manage_waiting_list: true }, - documents: { view: true, create: true, send_for_signing: true, upload_signed: true, delete: true }, - expenses: { view: true, create: true, edit: true, delete: true, export: true, scan_receipt: true }, - invoices: { view: true, create: true, edit: true, delete: true, send: true, record_payment: true, export: true }, + documents: { + view: true, + create: true, + send_for_signing: true, + upload_signed: true, + delete: true, + }, + expenses: { + view: true, + create: true, + edit: true, + delete: true, + export: true, + scan_receipt: true, + }, + invoices: { + view: true, + create: true, + edit: true, + delete: true, + send: true, + record_payment: true, + export: true, + }, files: { view: true, upload: true, delete: true, manage_folders: true }, email: { view: true, send: true, configure_account: true }, - reminders: { view_own: true, view_all: true, create: true, edit_own: true, edit_all: true, assign_others: true }, + reminders: { + view_own: true, + view_all: true, + create: true, + edit_own: true, + edit_all: true, + assign_others: true, + }, calendar: { connect: true, view_events: true }, reports: { view_dashboard: true, view_analytics: true, export: true }, document_templates: { view: true, generate: true, manage: true }, - admin: { manage_users: true, view_audit_log: true, manage_settings: true, manage_webhooks: true, manage_reports: true, manage_custom_fields: true, manage_forms: true, manage_tags: true, system_backup: true }, + yachts: { view: true, create: true, edit: true, delete: true, transfer: true }, + companies: { view: true, create: true, edit: true, delete: true }, + memberships: { view: true, manage: true }, + reservations: { view: true, create: true, activate: true, cancel: true }, + admin: { + manage_users: true, + view_audit_log: true, + manage_settings: true, + manage_webhooks: true, + manage_reports: true, + manage_custom_fields: true, + manage_forms: true, + manage_tags: true, + system_backup: true, + }, }; const DIRECTOR_PERMISSIONS: RolePermissions = { clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, - interests: { view: true, create: true, edit: true, delete: true, change_stage: true, generate_eoi: true, export: true }, + interests: { + view: true, + create: true, + edit: true, + delete: true, + change_stage: true, + generate_eoi: true, + export: true, + }, berths: { view: true, edit: true, import: true, manage_waiting_list: true }, - documents: { view: true, create: true, send_for_signing: true, upload_signed: true, delete: true }, - expenses: { view: true, create: true, edit: true, delete: true, export: true, scan_receipt: true }, - invoices: { view: true, create: true, edit: true, delete: true, send: true, record_payment: true, export: true }, + documents: { + view: true, + create: true, + send_for_signing: true, + upload_signed: true, + delete: true, + }, + expenses: { + view: true, + create: true, + edit: true, + delete: true, + export: true, + scan_receipt: true, + }, + invoices: { + view: true, + create: true, + edit: true, + delete: true, + send: true, + record_payment: true, + export: true, + }, files: { view: true, upload: true, delete: true, manage_folders: true }, email: { view: true, send: true, configure_account: true }, - reminders: { view_own: true, view_all: true, create: true, edit_own: true, edit_all: true, assign_others: true }, + reminders: { + view_own: true, + view_all: true, + create: true, + edit_own: true, + edit_all: true, + assign_others: true, + }, calendar: { connect: true, view_events: true }, reports: { view_dashboard: true, view_analytics: true, export: true }, document_templates: { view: true, generate: true, manage: true }, - admin: { manage_users: true, view_audit_log: true, manage_settings: true, manage_webhooks: true, manage_reports: true, manage_custom_fields: true, manage_forms: true, manage_tags: true, system_backup: false }, + yachts: { view: true, create: true, edit: true, delete: true, transfer: true }, + companies: { view: true, create: true, edit: true, delete: true }, + memberships: { view: true, manage: true }, + reservations: { view: true, create: true, activate: true, cancel: true }, + admin: { + manage_users: true, + view_audit_log: true, + manage_settings: true, + manage_webhooks: true, + manage_reports: true, + manage_custom_fields: true, + manage_forms: true, + manage_tags: true, + system_backup: false, + }, }; const SALES_MANAGER_PERMISSIONS: RolePermissions = { clients: { view: true, create: true, edit: true, delete: false, merge: true, export: true }, - interests: { view: true, create: true, edit: true, delete: false, change_stage: true, generate_eoi: true, export: true }, + interests: { + view: true, + create: true, + edit: true, + delete: false, + change_stage: true, + generate_eoi: true, + export: true, + }, berths: { view: true, edit: false, import: false, manage_waiting_list: true }, - documents: { view: true, create: true, send_for_signing: true, upload_signed: true, delete: false }, - expenses: { view: true, create: true, edit: true, delete: false, export: true, scan_receipt: true }, - invoices: { view: true, create: true, edit: true, delete: false, send: true, record_payment: true, export: true }, + documents: { + view: true, + create: true, + send_for_signing: true, + upload_signed: true, + delete: false, + }, + expenses: { + view: true, + create: true, + edit: true, + delete: false, + export: true, + scan_receipt: true, + }, + invoices: { + view: true, + create: true, + edit: true, + delete: false, + send: true, + record_payment: true, + export: true, + }, files: { view: true, upload: true, delete: false, manage_folders: true }, email: { view: true, send: true, configure_account: true }, - reminders: { view_own: true, view_all: true, create: true, edit_own: true, edit_all: true, assign_others: true }, + reminders: { + view_own: true, + view_all: true, + create: true, + edit_own: true, + edit_all: true, + assign_others: true, + }, calendar: { connect: true, view_events: true }, reports: { view_dashboard: true, view_analytics: true, export: true }, document_templates: { view: true, generate: true, manage: false }, - admin: { manage_users: false, view_audit_log: false, manage_settings: false, manage_webhooks: false, manage_reports: false, manage_custom_fields: false, manage_forms: false, manage_tags: true, system_backup: false }, + yachts: { view: true, create: true, edit: true, delete: false, transfer: true }, + companies: { view: true, create: true, edit: true, delete: false }, + memberships: { view: true, manage: true }, + reservations: { view: true, create: true, activate: true, cancel: true }, + admin: { + manage_users: false, + view_audit_log: false, + manage_settings: false, + manage_webhooks: false, + manage_reports: false, + manage_custom_fields: false, + manage_forms: false, + manage_tags: true, + system_backup: false, + }, }; const SALES_AGENT_PERMISSIONS: RolePermissions = { clients: { view: true, create: true, edit: true, delete: false, merge: false, export: true }, - interests: { view: true, create: true, edit: true, delete: false, change_stage: true, generate_eoi: true, export: true }, + interests: { + view: true, + create: true, + edit: true, + delete: false, + change_stage: true, + generate_eoi: true, + export: true, + }, berths: { view: true, edit: false, import: false, manage_waiting_list: true }, - documents: { view: true, create: true, send_for_signing: true, upload_signed: true, delete: false }, - expenses: { view: true, create: true, edit: true, delete: false, export: true, scan_receipt: true }, - invoices: { view: true, create: true, edit: true, delete: false, send: true, record_payment: true, export: true }, + documents: { + view: true, + create: true, + send_for_signing: true, + upload_signed: true, + delete: false, + }, + expenses: { + view: true, + create: true, + edit: true, + delete: false, + export: true, + scan_receipt: true, + }, + invoices: { + view: true, + create: true, + edit: true, + delete: false, + send: true, + record_payment: true, + export: true, + }, files: { view: true, upload: true, delete: false, manage_folders: false }, email: { view: true, send: true, configure_account: true }, - reminders: { view_own: true, view_all: false, create: true, edit_own: true, edit_all: false, assign_others: false }, + reminders: { + view_own: true, + view_all: false, + create: true, + edit_own: true, + edit_all: false, + assign_others: false, + }, calendar: { connect: true, view_events: true }, reports: { view_dashboard: true, view_analytics: true, export: true }, document_templates: { view: true, generate: true, manage: false }, - admin: { manage_users: false, view_audit_log: false, manage_settings: false, manage_webhooks: false, manage_reports: false, manage_custom_fields: false, manage_forms: false, manage_tags: true, system_backup: false }, + yachts: { view: true, create: true, edit: true, delete: false, transfer: false }, + companies: { view: true, create: true, edit: false, delete: false }, + memberships: { view: true, manage: false }, + reservations: { view: true, create: true, activate: true, cancel: false }, + admin: { + manage_users: false, + view_audit_log: false, + manage_settings: false, + manage_webhooks: false, + manage_reports: false, + manage_custom_fields: false, + manage_forms: false, + manage_tags: true, + system_backup: false, + }, }; const VIEWER_PERMISSIONS: RolePermissions = { clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false }, - interests: { view: true, create: false, edit: false, delete: false, change_stage: false, generate_eoi: false, export: false }, + interests: { + view: true, + create: false, + edit: false, + delete: false, + change_stage: false, + generate_eoi: false, + export: false, + }, berths: { view: true, edit: false, import: false, manage_waiting_list: false }, - documents: { view: true, create: false, send_for_signing: false, upload_signed: false, delete: false }, - expenses: { view: true, create: false, edit: false, delete: false, export: false, scan_receipt: false }, - invoices: { view: true, create: false, edit: false, delete: false, send: false, record_payment: false, export: false }, + documents: { + view: true, + create: false, + send_for_signing: false, + upload_signed: false, + delete: false, + }, + expenses: { + view: true, + create: false, + edit: false, + delete: false, + export: false, + scan_receipt: false, + }, + invoices: { + view: true, + create: false, + edit: false, + delete: false, + send: false, + record_payment: false, + export: false, + }, files: { view: true, upload: false, delete: false, manage_folders: false }, email: { view: true, send: false, configure_account: false }, - reminders: { view_own: true, view_all: false, create: false, edit_own: false, edit_all: false, assign_others: false }, + reminders: { + view_own: true, + view_all: false, + create: false, + edit_own: false, + edit_all: false, + assign_others: false, + }, calendar: { connect: false, view_events: true }, reports: { view_dashboard: true, view_analytics: false, export: false }, document_templates: { view: true, generate: false, manage: false }, - admin: { manage_users: false, view_audit_log: false, manage_settings: false, manage_webhooks: false, manage_reports: false, manage_custom_fields: false, manage_forms: false, manage_tags: false, system_backup: false }, + yachts: { view: true, create: false, edit: false, delete: false, transfer: false }, + companies: { view: true, create: false, edit: false, delete: false }, + memberships: { view: true, manage: false }, + reservations: { view: true, create: false, activate: false, cancel: false }, + admin: { + manage_users: false, + view_audit_log: false, + manage_settings: false, + manage_webhooks: false, + manage_reports: false, + manage_custom_fields: false, + manage_forms: false, + manage_tags: false, + system_backup: false, + }, }; // ─── Seed Function ──────────────────────────────────────────────────────────── @@ -158,7 +408,8 @@ async function seed() { { id: crypto.randomUUID(), name: 'sales_agent', - description: 'Standard sales role. View/create/edit clients and interests, manage own reminders.', + description: + 'Standard sales role. View/create/edit clients and interests, manage own reminders.', permissions: SALES_AGENT_PERMISSIONS, isGlobal: true, isSystem: true, diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts index 188f9fb..656fec6 100644 --- a/tests/helpers/factories.ts +++ b/tests/helpers/factories.ts @@ -224,6 +224,10 @@ export function makeFullPermissions(): RolePermissions { calendar: { connect: true, view_events: true }, reports: { view_dashboard: true, view_analytics: true, export: true }, document_templates: { view: true, generate: true, manage: true }, + yachts: { view: true, create: true, edit: true, delete: true, transfer: true }, + companies: { view: true, create: true, edit: true, delete: true }, + memberships: { view: true, manage: true }, + reservations: { view: true, create: true, activate: true, cancel: true }, admin: { manage_users: true, view_audit_log: true, @@ -289,6 +293,10 @@ export function makeViewerPermissions(): RolePermissions { calendar: { connect: false, view_events: true }, reports: { view_dashboard: true, view_analytics: false, export: false }, document_templates: { view: true, generate: false, manage: false }, + yachts: { view: true, create: false, edit: false, delete: false, transfer: false }, + companies: { view: true, create: false, edit: false, delete: false }, + memberships: { view: true, manage: false }, + reservations: { view: true, create: false, activate: false, cancel: false }, admin: { manage_users: false, view_audit_log: false, @@ -354,6 +362,10 @@ export function makeSalesAgentPermissions(): RolePermissions { calendar: { connect: true, view_events: true }, reports: { view_dashboard: true, view_analytics: false, export: false }, document_templates: { view: true, generate: true, manage: false }, + yachts: { view: true, create: true, edit: true, delete: false, transfer: false }, + companies: { view: true, create: true, edit: false, delete: false }, + memberships: { view: true, manage: false }, + reservations: { view: true, create: true, activate: true, cancel: false }, admin: { manage_users: false, view_audit_log: false, @@ -419,6 +431,10 @@ export function makeSalesManagerPermissions(): RolePermissions { calendar: { connect: true, view_events: true }, reports: { view_dashboard: true, view_analytics: true, export: true }, document_templates: { view: true, generate: true, manage: false }, + yachts: { view: true, create: true, edit: true, delete: false, transfer: true }, + companies: { view: true, create: true, edit: true, delete: false }, + memberships: { view: true, manage: true }, + reservations: { view: true, create: true, activate: true, cancel: true }, admin: { manage_users: false, view_audit_log: true, diff --git a/tests/integration/permission-matrix.test.ts b/tests/integration/permission-matrix.test.ts index 8982a20..503897a 100644 --- a/tests/integration/permission-matrix.test.ts +++ b/tests/integration/permission-matrix.test.ts @@ -16,7 +16,6 @@ import { describe, it, expect, vi } from 'vitest'; import { withPermission, deepMerge, type AuthContext } from '@/lib/api/helpers'; import { - makeFullPermissions, makeViewerPermissions, makeSalesAgentPermissions, makeSalesManagerPermissions, @@ -237,7 +236,9 @@ describe('deepMerge — permission override merging', () => { it('override with full-permission block gives full access', () => { const base = makeViewerPermissions() as Record; - const override = { clients: { create: true, edit: true, delete: true, merge: true, export: true } }; + const override = { + clients: { create: true, edit: true, delete: true, merge: true, export: true }, + }; const result = deepMerge(base, override) as RolePermissions; expect(result.clients.create).toBe(true); expect(result.clients.view).toBe(true); // preserved from base @@ -250,3 +251,73 @@ describe('deepMerge — permission override merging', () => { expect(result.events).toEqual(['c']); }); }); + +// ─── new resources (yachts, companies, memberships, reservations) ──────────── + +describe('new resources (yachts, companies, memberships, reservations)', () => { + it('super_admin bypasses all new resource permissions', async () => { + const ctx = makeCtx({ isSuperAdmin: true, permissions: null }); + const handler = vi.fn(okHandler()); + const wrapped = withPermission('yachts', 'transfer', handler); + const res = await wrapped(makeRequest(), ctx, {}); + expect(res.status).toBe(200); + }); + + it('viewer can yachts.view but not yachts.transfer', async () => { + const ctx = makeCtx({ permissions: makeViewerPermissions() }); + const viewRes = await withPermission('yachts', 'view', vi.fn(okHandler()))( + makeRequest(), + ctx, + {}, + ); + expect(viewRes.status).toBe(200); + const transferRes = await withPermission('yachts', 'transfer', vi.fn(okHandler()))( + makeRequest(), + ctx, + {}, + ); + expect(transferRes.status).toBe(403); + }); + + it('sales_manager can yachts.transfer and memberships.manage', async () => { + const ctx = makeCtx({ permissions: makeSalesManagerPermissions() }); + const transferRes = await withPermission('yachts', 'transfer', vi.fn(okHandler()))( + makeRequest(), + ctx, + {}, + ); + expect(transferRes.status).toBe(200); + const manageRes = await withPermission('memberships', 'manage', vi.fn(okHandler()))( + makeRequest(), + ctx, + {}, + ); + expect(manageRes.status).toBe(200); + }); + + it('sales_agent can reservations.activate but not reservations.cancel', async () => { + const ctx = makeCtx({ permissions: makeSalesAgentPermissions() }); + const activateRes = await withPermission('reservations', 'activate', vi.fn(okHandler()))( + makeRequest(), + ctx, + {}, + ); + expect(activateRes.status).toBe(200); + const cancelRes = await withPermission('reservations', 'cancel', vi.fn(okHandler()))( + makeRequest(), + ctx, + {}, + ); + expect(cancelRes.status).toBe(403); + }); + + it('sales_agent cannot companies.delete', async () => { + const ctx = makeCtx({ permissions: makeSalesAgentPermissions() }); + const res = await withPermission('companies', 'delete', vi.fn(okHandler()))( + makeRequest(), + ctx, + {}, + ); + expect(res.status).toBe(403); + }); +});