Files
pn-new-crm/src/lib/db/schema/users.ts
Matt 661187cc79 feat(client-groups): CM-1 data layer — groups entity, membership, service, Mailchimp scaffold
- client_groups + client_group_members tables (migration 0094, port_id cascade)
- client_groups permission resource (view/manage) in catalog + role backfill
- service: CRUD + wipe-and-rewrite membership + member email resolution
- mailchimp.service scaffold: config reader + inert one-way sync (mapping
  deferred until the client's MC account is wired, per CM-1 decision)
- 4 integration tests (CRUD, membership, email resolution, port-scope guard)

Backend only — API routes + UI to follow. tsc clean, 1635 vitest pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:28:20 +02:00

504 lines
18 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. 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;
/**
* Update berth `price` / `priceCurrency` via the dedicated single
* (`PATCH /api/v1/berths/[id]/price`) and bulk
* (`POST /api/v1/berths/bulk-update-prices`) endpoints. Carved out
* from generic `berths.edit` so admins can grant sales reps the
* ability to retune prices without exposing the full berth-edit
* surface (dimensions, mooring type, etc.). Always audited.
*/
update_prices: 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;
};
tenancies: {
view: boolean;
manage: 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;
};
inquiries: {
view: boolean;
manage: boolean;
};
client_groups: {
view: boolean;
manage: 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[];
/** Row density. Absent = comfortable (the default; matches shadcn's
* default cell padding). 'compact' drops vertical padding so reps
* can scan more rows per viewport. */
density?: 'comfortable' | 'compact';
/** Dimension unit preference for tables that render boat / berth
* measurements. Absent = inferred from the user's locale; 'ft' for
* US/UK defaults, 'm' elsewhere. Per-table override via the column
* header toggle. */
dimensionUnit?: 'ft' | 'm';
};
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>;
/**
* Ordered list of widget ids for the **desktop / xl layout** (charts
* column + rails aside + feed row, side-by-side). Drives the render
* order at viewport widths >= 1280px. Missing widgets fall through to
* registry order so newly-added widgets always surface.
*/
dashboardWidgetOrder?: string[];
/**
* Ordered list of widget ids for the **stacked layout** (single
* column at < xl). Reps reasonably want a different order on mobile
* vs desktop - Reminders + Activity top on the phone, Pipeline Funnel
* top on a 27" monitor. When unset, the dashboard falls back to
* `dashboardWidgetOrder` (then registry order) so a rep who only
* customized desktop sees the same order on a phone until they
* customize there too.
*/
dashboardWidgetOrderMobile?: string[];
[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()),
// onDelete: 'cascade' is intentional here (not 'set null' as a stale 2026-05-12
// audit item suggested). A permission override has no semantic value without
// the user it grants permissions to - preserving a row with user_id=NULL
// would be an orphan with no audit value, since the override is per-user
// additive permissions, not a historical event we need to retain.
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<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(() => 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;