Files
pn-new-crm/src/lib/db/schema/users.ts
Matt 905852b8a5 feat(permissions): carve out dedicated payments resource
Payments (deposit / balance / refund records on an interest) used to
share `invoices.record_payment`, which forces a port that doesn't
issue invoices at all to still navigate the invoicing permission
group to grant its sales reps payment-recording rights. Splitting
the resource lets admins gate the two surfaces independently.

The new resource has three actions:
  - view   — gates the UI affordance (API reads still go through
             `interests.view`)
  - record — POST / PATCH a payment
  - delete — DELETE a payment record

Seed maps updated for all six system roles; existing role rows +
per-user permission overrides are backfilled by migration 0064 so
upgrades don't silently lose access. Two call sites (POST /interests/
[id]/payments, PATCH /payments/[id]) → payments.record; one
(DELETE /payments/[id]) → payments.delete. The PermissionGates on the
payments-section UI swap to the new keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:46:01 +02:00

457 lines
16 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;
};
/**
* 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 `<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()
.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;