Files
pn-new-crm/src/lib/db/schema/users.ts
Matt 4b9743a594 audit: 33-agent comprehensive audit + critical fixes
Full team audit run, all reports verbatim in docs/AUDIT-2026-05-12.md
(5900+ lines, 30+ critical findings). Already-fixed this commit:
- permission-overrides PUT: self-target block + RolePermissions allow-list + cross-tenant guard
- /api/auth/resolve-identifier: rate-limit + synthetic miss-email kill enumeration
- admin email-change: rotates account.accountId + revokes sessions
- middleware: token-gated email confirm/cancel routes whitelisted
- NAV_CATALOG: 10 dead-link sweeps to existing /admin/<x> targets

Feature work landing same commit: optional username sign-in
(migration 0054), per-user permission overrides (0055) with three-state
matrix tabbed inside UserForm, user disable button, role + outcome +
stage label normalisation across the platform, admin email-change
with auto-notification template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:52:35 +02:00

441 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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. mark a contract_signed
* deal as completed without going through deposit_10pct 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;
};
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 `<ColumnPicker>` 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<string, TablePreferences>;
/**
* 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<string, boolean>;
[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,
* 330 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<UserPreferences>().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<RolePermissions>()
.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<RolePermissions>` 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(),
portId: text('port_id')
.notNull()
.references(() => ports.id, { onDelete: 'cascade' }),
permissionOverrides: jsonb('permission_overrides')
.$type<Partial<RolePermissions>>()
.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<Partial<RolePermissions>>()
.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 Better Auth user ID
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;