import { pgTable, text, boolean, timestamp, jsonb, index, uniqueIndex } from 'drizzle-orm/pg-core'; import { ports } from './ports'; // ─── Permission Types ───────────────────────────────────────────────────────── export type RolePermissions = { clients: { view: boolean; create: boolean; edit: boolean; delete: boolean; merge: boolean; export: boolean; }; interests: { view: boolean; create: boolean; edit: boolean; delete: boolean; change_stage: boolean; /** Bypass the canTransitionStage table (e.g. jump a deal straight to * Contract without going through Deposit Paid first when the data * was entered out of order). Audit-logged with the reason the rep * gives. Sales-team-restricted. */ override_stage: boolean; generate_eoi: boolean; export: boolean; }; berths: { view: boolean; edit: boolean; import: boolean; manage_waiting_list: boolean; }; documents: { view: boolean; create: boolean; edit: boolean; send_for_signing: boolean; upload_signed: boolean; delete: boolean; manage_folders: boolean; }; expenses: { view: boolean; create: boolean; edit: boolean; delete: boolean; export: boolean; scan_receipt: boolean; }; invoices: { view: boolean; create: boolean; edit: boolean; delete: boolean; send: boolean; record_payment: boolean; export: boolean; }; /** * Standalone payments resource (deposit / balance / refund records on * an interest). Carved out from `invoices.record_payment` so a port * that does not use the invoicing module at all can still grant * payment-recording rights to sales reps. `view` follows interests.view * at the route level — this gate only governs the UI affordance. */ payments: { view: boolean; record: boolean; delete: boolean; }; files: { view: boolean; upload: boolean; edit: boolean; delete: boolean; manage_folders: boolean; }; email: { view: boolean; send: boolean; configure_account: boolean; }; reminders: { view_own: boolean; view_all: boolean; create: boolean; edit_own: boolean; edit_all: boolean; assign_others: boolean; }; calendar: { connect: boolean; view_events: boolean; }; reports: { view_dashboard: boolean; view_analytics: boolean; export: boolean; }; document_templates: { view: boolean; 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; manage_settings: boolean; manage_webhooks: boolean; manage_reports: boolean; manage_custom_fields: boolean; manage_forms: boolean; manage_tags: boolean; system_backup: boolean; // Permanent client deletion is gated separately from admin.manage_users // because it bypasses archive/restore. Requires email-code confirmation. permanently_delete_clients: boolean; }; residential_clients: { view: boolean; create: boolean; edit: boolean; delete: boolean; }; residential_interests: { view: boolean; create: boolean; edit: boolean; delete: boolean; change_stage: boolean; }; }; /** * Per-table column visibility — drives the `` and the * DataTable `columnVisibility` state. `hiddenColumns` is the source of * truth; an entry's absence means "show this column" (so newly-added * columns show by default for existing users without us having to * migrate stored preferences). */ export type TablePreferences = { hiddenColumns?: string[]; }; export type UserPreferences = { dark_mode?: boolean; locale?: string; timezone?: string; /** ISO-3166-1 alpha-2. Drives the default timezone when the rep * hasn't picked one explicitly, and lets the auto-detect banner * spot a mismatch when they're travelling. */ country?: string; /** Keyed by entity type: `clients`, `yachts`, `interests`, etc. */ tablePreferences?: Record; /** * Dashboard widget visibility, keyed by widget id from the registry * in `src/components/dashboard/widget-registry.ts`. Missing keys fall * back to `defaultVisible` from the registry — so adding a new widget * surfaces it for everyone without a migration. `false` hides it. */ dashboardWidgets?: Record; [key: string]: unknown; }; // ─── Better Auth Core Tables ───────────────────────────────────────────────── /** * Core user table managed by Better Auth. * Do NOT modify directly - Better Auth handles CRUD via its adapter. */ export const user = pgTable('user', { id: text('id').primaryKey(), name: text('name').notNull(), email: text('email').notNull().unique(), emailVerified: boolean('email_verified').notNull().default(false), image: text('image'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); 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), accessToken: text('access_token'), refreshToken: text('refresh_token'), idToken: text('id_token'), accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }), refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }), scope: text('scope'), password: text('password'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); export const verification = pgTable('verification', { id: text('id').primaryKey(), identifier: text('identifier').notNull(), value: text('value').notNull(), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), createdAt: timestamp('created_at', { withTimezone: true }), updatedAt: timestamp('updated_at', { withTimezone: true }), }); // ─── CRM Extension Tables ─────────────────────────────────────────────────── /** * Extension table for Better Auth users. * Better Auth manages the core `user` table. * We extend with CRM-specific fields here. */ export const userProfiles = pgTable( 'user_profiles', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), userId: text('user_id').notNull().unique(), // references Better Auth user ID /** * Canonical first/last name pair. Added 2026-05-09 as the primary * source for greetings, invoicing, and DocSign field-merging — the * older `displayName` is now kept around as a derived/optional * override (e.g. for nicknames or vanity formatting). When migrating * production, backfill these columns from displayName by splitting * on the first space and zero-pad the trailing column with NULL so * single-token names don't fail the not-null assumption. */ firstName: text('first_name'), lastName: text('last_name'), displayName: text('display_name').notNull(), /** * Optional sign-in alias. Lowercase a-z0-9 plus dot/underscore/hyphen, * 3–30 chars (shape pinned by `chk_user_profiles_username_shape`). * Case-insensitive uniqueness is enforced by a partial unique index on * LOWER(username); NULL allows the column to coexist with users who * still sign in by email. See migration 0054. */ username: text('username'), avatarUrl: text('avatar_url'), /** FK into the polymorphic `files` table — the avatar is stored * via getStorageBackend() so an S3↔filesystem swap carries it * without breaking the URL. The legacy `avatarUrl` column is * kept for any external photo sources but the file pointer wins * when both are set. */ avatarFileId: text('avatar_file_id'), phone: text('phone'), isSuperAdmin: boolean('is_super_admin').notNull().default(false), isActive: boolean('is_active').notNull().default(true), lastLoginAt: timestamp('last_login_at', { withTimezone: true }), preferences: jsonb('preferences').$type().notNull().default({}), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [uniqueIndex('user_profiles_user_id_idx').on(table.userId)], ); export const roles = pgTable('roles', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), name: text('name').notNull(), description: text('description'), 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(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); /** * Per-user permission overrides layered on top of the role's baseline for * a specific port. Each row carries a `Partial` map; any * explicitly-set leaf wins over the role + port-role-override chain. Most * users will never have a row here — it exists for the rare "give Alice * the same role as her team but let her run permanent deletes" case. * * Effective permission resolution lives in `getEffectivePermissions` in * src/lib/services/permissions.service.ts. */ export const userPermissionOverrides = pgTable( 'user_permission_overrides', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), portId: text('port_id') .notNull() .references(() => ports.id, { onDelete: 'cascade' }), permissionOverrides: jsonb('permission_overrides') .$type>() .notNull() .default({}), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ uniqueIndex('idx_user_perm_overrides_user_port').on(table.userId, table.portId), index('idx_user_perm_overrides_user').on(table.userId), ], ); export type UserPermissionOverride = typeof userPermissionOverrides.$inferSelect; export type NewUserPermissionOverride = typeof userPermissionOverrides.$inferInsert; export const portRoleOverrides = pgTable( 'port_role_overrides', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), portId: text('port_id') .notNull() .references(() => ports.id, { onDelete: 'cascade' }), roleId: text('role_id') .notNull() .references(() => roles.id, { onDelete: 'cascade' }), permissionOverrides: jsonb('permission_overrides') .$type>() .notNull() .default({}), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ uniqueIndex('port_role_overrides_port_role_idx').on(table.portId, table.roleId), index('port_role_overrides_port_idx').on(table.portId), ], ); /** * Pending email-change records for the verify-old-and-new flow. * The CRM's `/api/v1/me/email` endpoint creates a row here, emails * the OLD address with a cancel link and the NEW address with a * confirm link, and applies the change only when the new address * confirms (or auto-cancels at `expiresAt`). * * `confirmTokenHash` stores a sha256 of the random confirmation * token; the raw token is only present in the email body. */ export const userEmailChanges = pgTable( 'user_email_changes', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), userId: text('user_id').notNull(), oldEmail: text('old_email').notNull(), newEmail: text('new_email').notNull(), confirmTokenHash: text('confirm_token_hash').notNull(), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), appliedAt: timestamp('applied_at', { withTimezone: true }), cancelledAt: timestamp('cancelled_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ index('idx_uec_user').on(table.userId), index('idx_uec_token').on(table.confirmTokenHash), ], ); export const userPortRoles = pgTable( 'user_port_roles', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), portId: text('port_id') .notNull() .references(() => ports.id, { onDelete: 'cascade' }), roleId: text('role_id') .notNull() .references(() => roles.id, { onDelete: 'cascade' }), /** * Per-user per-port toggle that grants full residential domain access * (residential_clients.* and residential_interests.*) on top of the * user's primary role. Lets admins flip residential access for sales * staff individually without minting a second role. */ residentialAccess: boolean('residential_access').notNull().default(false), assignedBy: text('assigned_by'), // user ID of who assigned this createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ uniqueIndex('user_port_roles_user_port_role_idx').on(table.userId, table.portId, table.roleId), index('idx_upr_user').on(table.userId), index('idx_upr_port').on(table.portId), ], ); /** * Sessions table - Better Auth compatibility. * Better Auth manages session creation/validation. */ export const session = pgTable( 'session', { id: text('id').primaryKey(), userId: text('user_id').notNull(), token: text('token').notNull().unique(), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), ipAddress: text('ip_address'), userAgent: text('user_agent'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ uniqueIndex('sessions_token_idx').on(table.token), index('sessions_user_id_idx').on(table.userId), ], ); export type UserProfile = typeof userProfiles.$inferSelect; export type NewUserProfile = typeof userProfiles.$inferInsert; export type Role = typeof roles.$inferSelect; export type NewRole = typeof roles.$inferInsert; export type PortRoleOverride = typeof portRoleOverrides.$inferSelect; export type NewPortRoleOverride = typeof portRoleOverrides.$inferInsert; export type UserPortRole = typeof userPortRoles.$inferSelect; export type NewUserPortRole = typeof userPortRoles.$inferInsert;