Initial commit: Port Nimara CRM (Layers 0-4)
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled

Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 11:52:51 +01:00
commit 67d7e6e3d5
572 changed files with 86496 additions and 0 deletions

20
src/lib/db/index.ts Normal file
View File

@@ -0,0 +1,20 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
const connectionString = process.env.DATABASE_URL!;
// Connection pool for queries.
const queryClient = postgres(connectionString, {
max: 20,
idle_timeout: 20,
connect_timeout: 10,
});
export const db = drizzle(queryClient, {
schema,
logger: process.env.NODE_ENV === 'development',
});
export type Database = typeof db;

109
src/lib/db/query-builder.ts Normal file
View File

@@ -0,0 +1,109 @@
import {
and,
asc,
desc,
eq,
ilike,
isNull,
or,
sql,
type SQL,
} from 'drizzle-orm';
import type { PgTable, PgColumn } from 'drizzle-orm/pg-core';
import { db } from './index';
export interface BuildListQueryOptions {
table: PgTable;
portIdColumn: PgColumn;
portId: string;
idColumn: PgColumn;
updatedAtColumn: PgColumn;
filters?: SQL[];
sort?: { column: PgColumn; direction: 'asc' | 'desc' };
page: number;
pageSize: number;
searchColumns?: PgColumn[];
searchTerm?: string;
includeArchived?: boolean;
archivedAtColumn?: PgColumn;
}
export interface ListResult<T> {
data: T[];
total: number;
}
/**
* Generic Drizzle paginated query builder with port-scoping.
*
* - Port scoping is always the first condition in the AND chain.
* - `archivedAt IS NULL` by default (unless `includeArchived` is true).
* - Deterministic secondary sort: `updatedAt DESC, id DESC`.
*/
export async function buildListQuery<T>(
opts: BuildListQueryOptions,
): Promise<ListResult<T>> {
const {
table,
portIdColumn,
portId,
idColumn,
updatedAtColumn,
filters = [],
sort,
page,
pageSize,
searchColumns = [],
searchTerm,
includeArchived = false,
archivedAtColumn,
} = opts;
const conditions: SQL[] = [eq(portIdColumn, portId)];
// Exclude archived by default
if (!includeArchived && archivedAtColumn) {
conditions.push(isNull(archivedAtColumn));
}
// Full-text search across multiple columns via ILIKE
if (searchTerm && searchColumns.length > 0) {
const searchConditions = searchColumns.map((col) =>
ilike(col, `%${searchTerm}%`),
);
conditions.push(or(...searchConditions)!);
}
// Append caller-supplied filters
conditions.push(...filters);
const where = and(...conditions);
// Count total
const countResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(table)
.where(where);
const total = countResult[0]?.count ?? 0;
// Build order by: user sort + deterministic secondary sort
const orderClauses: SQL[] = [];
if (sort) {
orderClauses.push(
sort.direction === 'asc' ? asc(sort.column) : desc(sort.column),
);
}
orderClauses.push(desc(updatedAtColumn), desc(idColumn));
// Fetch page
const offset = (page - 1) * pageSize;
const data = await db
.select()
.from(table)
.where(where)
.orderBy(...orderClauses)
.limit(pageSize)
.offset(offset);
return { data: data as T[], total };
}

178
src/lib/db/schema/berths.ts Normal file
View File

@@ -0,0 +1,178 @@
import {
pgTable,
text,
boolean,
integer,
numeric,
timestamp,
date,
jsonb,
index,
uniqueIndex,
primaryKey,
} from 'drizzle-orm/pg-core';
import { ports } from './ports';
import { clients } from './clients';
export const berths = pgTable(
'berths',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
mooringNumber: text('mooring_number').notNull(),
area: text('area'),
status: text('status').notNull().default('available'), // available, under_offer, sold
lengthFt: numeric('length_ft'),
widthFt: numeric('width_ft'),
draftFt: numeric('draft_ft'),
lengthM: numeric('length_m'),
widthM: numeric('width_m'),
draftM: numeric('draft_m'),
widthIsMinimum: boolean('width_is_minimum').default(false),
nominalBoatSize: text('nominal_boat_size'),
nominalBoatSizeM: text('nominal_boat_size_m'),
waterDepth: numeric('water_depth'),
waterDepthM: numeric('water_depth_m'),
waterDepthIsMinimum: boolean('water_depth_is_minimum').default(false),
sidePontoon: text('side_pontoon'),
powerCapacity: text('power_capacity'),
voltage: text('voltage'),
mooringType: text('mooring_type'),
cleatType: text('cleat_type'),
cleatCapacity: text('cleat_capacity'),
bollardType: text('bollard_type'),
bollardCapacity: text('bollard_capacity'),
access: text('access'),
price: numeric('price'),
priceCurrency: text('price_currency').notNull().default('USD'),
bowFacing: text('bow_facing'),
berthApproved: boolean('berth_approved').default(false),
tenureType: text('tenure_type').notNull().default('permanent'), // permanent, fixed_term
tenureYears: integer('tenure_years'),
tenureStartDate: date('tenure_start_date'),
tenureEndDate: date('tenure_end_date'),
statusLastChangedBy: text('status_last_changed_by'), // user ID
statusLastChangedReason: text('status_last_changed_reason'),
statusLastModified: timestamp('status_last_modified', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_berths_port').on(table.portId),
index('idx_berths_status').on(table.portId, table.status),
index('idx_berths_area').on(table.portId, table.area),
uniqueIndex('idx_berths_mooring').on(table.portId, table.mooringNumber),
],
);
export const berthMapData = pgTable(
'berth_map_data',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
berthId: text('berth_id')
.notNull()
.unique()
.references(() => berths.id, { onDelete: 'cascade' }),
svgPath: text('svg_path'),
x: numeric('x'),
y: numeric('y'),
transform: text('transform'),
fontSize: numeric('font_size'),
extraData: jsonb('extra_data').default({}),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [uniqueIndex('berth_map_data_berth_id_idx').on(table.berthId)],
);
export const berthRecommendations = pgTable(
'berth_recommendations',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
interestId: text('interest_id').notNull(), // references interests.id
berthId: text('berth_id')
.notNull()
.references(() => berths.id, { onDelete: 'cascade' }),
matchScore: numeric('match_score'), // 0-100
matchReasons: jsonb('match_reasons'), // { "dimensional_fit": 95, "power_match": 80, ... }
source: text('source').notNull().default('ai'), // ai, manual
createdBy: text('created_by'), // user ID for manual recommendations
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('berth_rec_interest_berth_idx').on(table.interestId, table.berthId),
index('idx_br_interest').on(table.interestId),
],
);
export const berthWaitingList = pgTable(
'berth_waiting_list',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
berthId: text('berth_id')
.notNull()
.references(() => berths.id, { onDelete: 'cascade' }),
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'cascade' }),
position: integer('position').notNull(),
priority: text('priority').notNull().default('normal'), // normal, high
notifyPref: text('notify_pref').default('email'), // email, in_app, both
notes: text('notes'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('berth_waiting_list_berth_client_idx').on(table.berthId, table.clientId),
index('idx_bwl_berth').on(table.berthId, table.position),
],
);
export const berthMaintenanceLog = pgTable(
'berth_maintenance_log',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
berthId: text('berth_id')
.notNull()
.references(() => berths.id, { onDelete: 'cascade' }),
portId: text('port_id')
.notNull()
.references(() => ports.id),
category: text('category').notNull(), // routine, repair, inspection, upgrade
description: text('description').notNull(),
cost: numeric('cost'),
costCurrency: text('cost_currency').default('USD'),
responsibleParty: text('responsible_party'),
performedDate: date('performed_date').notNull(),
photoFileIds: text('photo_file_ids').array(), // references to files table
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_bml_berth').on(table.berthId),
index('idx_bml_port').on(table.portId),
],
);
export const berthTags = pgTable(
'berth_tags',
{
berthId: text('berth_id')
.notNull()
.references(() => berths.id, { onDelete: 'cascade' }),
tagId: text('tag_id').notNull(), // references tags.id
},
(table) => [primaryKey({ columns: [table.berthId, table.tagId] })],
);
export type Berth = typeof berths.$inferSelect;
export type NewBerth = typeof berths.$inferInsert;
export type BerthMapData = typeof berthMapData.$inferSelect;
export type NewBerthMapData = typeof berthMapData.$inferInsert;
export type BerthRecommendation = typeof berthRecommendations.$inferSelect;
export type NewBerthRecommendation = typeof berthRecommendations.$inferInsert;
export type BerthWaitingList = typeof berthWaitingList.$inferSelect;
export type NewBerthWaitingList = typeof berthWaitingList.$inferInsert;
export type BerthMaintenanceLog = typeof berthMaintenanceLog.$inferSelect;
export type NewBerthMaintenanceLog = typeof berthMaintenanceLog.$inferInsert;

View File

@@ -0,0 +1,150 @@
import {
pgTable,
text,
boolean,
timestamp,
numeric,
jsonb,
index,
uniqueIndex,
primaryKey,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { ports } from './ports';
export const clients = pgTable(
'clients',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
fullName: text('full_name').notNull(),
companyName: text('company_name'),
nationality: text('nationality'),
isProxy: boolean('is_proxy').notNull().default(false),
proxyType: text('proxy_type'), // broker, representative, family_member, legal_counsel, other
actualOwnerName: text('actual_owner_name'),
relationshipNotes: text('relationship_notes'),
yachtName: text('yacht_name'),
yachtLengthFt: numeric('yacht_length_ft'),
yachtWidthFt: numeric('yacht_width_ft'),
yachtDraftFt: numeric('yacht_draft_ft'),
yachtLengthM: numeric('yacht_length_m'),
yachtWidthM: numeric('yacht_width_m'),
yachtDraftM: numeric('yacht_draft_m'),
berthSizeDesired: text('berth_size_desired'),
preferredContactMethod: text('preferred_contact_method'), // email, phone, whatsapp
preferredLanguage: text('preferred_language'),
timezone: text('timezone'),
source: text('source'), // website, manual, referral, broker
sourceDetails: text('source_details'),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_clients_port').on(table.portId),
index('idx_clients_name').on(table.portId, table.fullName),
index('idx_clients_archived').on(table.portId, table.archivedAt),
],
);
export const clientContacts = pgTable(
'client_contacts',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'cascade' }),
channel: text('channel').notNull(), // email, phone, whatsapp, other
value: text('value').notNull(),
label: text('label'), // primary, secondary, work, personal, broker, assistant
isPrimary: boolean('is_primary').notNull().default(false),
notes: text('notes'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_cc_client').on(table.clientId),
index('idx_cc_email').on(table.channel, table.value).where(sql`${table.channel} = 'email'`),
index('idx_cc_phone').on(table.channel, table.value).where(sql`${table.channel} = 'phone'`),
],
);
export const clientRelationships = pgTable(
'client_relationships',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
clientAId: text('client_a_id')
.notNull()
.references(() => clients.id, { onDelete: 'cascade' }),
clientBId: text('client_b_id')
.notNull()
.references(() => clients.id, { onDelete: 'cascade' }),
relationshipType: text('relationship_type').notNull(), // referred_by, broker_for, family_member, same_vessel, custom
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_cr_port').on(table.portId)],
);
export const clientNotes = pgTable(
'client_notes',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'cascade' }),
authorId: text('author_id').notNull(), // user ID
content: text('content').notNull(),
mentions: text('mentions').array(), // array of mentioned user IDs
isLocked: boolean('is_locked').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_cn_client').on(table.clientId)],
);
export const clientTags = pgTable(
'client_tags',
{
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'cascade' }),
tagId: text('tag_id').notNull(), // references tags.id — defined later in system.ts
},
(table) => [primaryKey({ columns: [table.clientId, table.tagId] })],
);
export const clientMergeLog = pgTable(
'client_merge_log',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
survivingClientId: text('surviving_client_id')
.notNull()
.references(() => clients.id),
mergedClientId: text('merged_client_id').notNull(), // the client that was merged away (may no longer exist)
mergedBy: text('merged_by').notNull(), // user ID
mergeDetails: jsonb('merge_details').notNull(), // which fields were kept from which record
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_cml_port').on(table.portId)],
);
export type Client = typeof clients.$inferSelect;
export type NewClient = typeof clients.$inferInsert;
export type ClientContact = typeof clientContacts.$inferSelect;
export type NewClientContact = typeof clientContacts.$inferInsert;
export type ClientRelationship = typeof clientRelationships.$inferSelect;
export type NewClientRelationship = typeof clientRelationships.$inferInsert;
export type ClientNote = typeof clientNotes.$inferSelect;
export type NewClientNote = typeof clientNotes.$inferInsert;
export type ClientMergeLog = typeof clientMergeLog.$inferSelect;
export type NewClientMergeLog = typeof clientMergeLog.$inferInsert;

View File

@@ -0,0 +1,184 @@
import {
pgTable,
text,
boolean,
integer,
timestamp,
jsonb,
index,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { ports } from './ports';
import { clients } from './clients';
export const files = pgTable(
'files',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
clientId: text('client_id').references(() => clients.id),
filename: text('filename').notNull(),
originalName: text('original_name').notNull(),
mimeType: text('mime_type'),
sizeBytes: text('size_bytes'), // stored as text to avoid bigint issues; parse as number in app
storagePath: text('storage_path').notNull(),
storageBucket: text('storage_bucket').notNull().default('crm-files'),
category: text('category'), // eoi, contract, image, receipt, correspondence, misc
uploadedBy: text('uploaded_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_files_port').on(table.portId),
index('idx_files_client').on(table.clientId),
],
);
export const documents = pgTable(
'documents',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
interestId: text('interest_id'), // references interests.id
clientId: text('client_id').references(() => clients.id),
documentType: text('document_type').notNull(), // eoi, contract, nda, reservation_agreement, other
title: text('title').notNull(),
status: text('status').notNull().default('draft'), // draft, sent, partially_signed, completed, expired, cancelled
documensoId: text('documenso_id'),
fileId: text('file_id').references(() => files.id),
signedFileId: text('signed_file_id').references(() => files.id),
isManualUpload: boolean('is_manual_upload').notNull().default(false),
notes: text('notes'),
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_docs_port').on(table.portId),
index('idx_docs_interest').on(table.interestId),
index('idx_docs_client').on(table.clientId),
index('idx_docs_type').on(table.portId, table.documentType),
],
);
export const documentSigners = pgTable(
'document_signers',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
documentId: text('document_id')
.notNull()
.references(() => documents.id, { onDelete: 'cascade' }),
signerName: text('signer_name').notNull(),
signerEmail: text('signer_email').notNull(),
signerRole: text('signer_role').notNull(), // client, developer, sales, approver, other
signingOrder: integer('signing_order').notNull(),
status: text('status').notNull().default('pending'), // pending, signed, declined
signedAt: timestamp('signed_at', { withTimezone: true }),
signingUrl: text('signing_url'),
embeddedUrl: text('embedded_url'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_ds_doc').on(table.documentId)],
);
export const documentEvents = pgTable(
'document_events',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
documentId: text('document_id')
.notNull()
.references(() => documents.id, { onDelete: 'cascade' }),
eventType: text('event_type').notNull(), // created, sent, viewed, signed, completed, expired, reminder_sent
signerId: text('signer_id').references(() => documentSigners.id),
eventData: jsonb('event_data').default({}),
signatureHash: text('signature_hash'), // deduplication
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_de_doc').on(table.documentId),
uniqueIndex('idx_de_dedup').on(table.documentId, table.signatureHash).where(
sql`${table.signatureHash} IS NOT NULL`
),
],
);
export const documentTemplates = pgTable(
'document_templates',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
name: text('name').notNull(),
description: text('description'),
templateType: text('template_type').notNull(), // welcome_letter, handover_checklist, acknowledgment, correspondence, custom
bodyHtml: text('body_html').notNull(),
mergeFields: jsonb('merge_fields').notNull().default([]),
isActive: boolean('is_active').notNull().default(true),
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_dt_port').on(table.portId),
index('idx_dt_type').on(table.portId, table.templateType),
],
);
export const formTemplates = pgTable(
'form_templates',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
name: text('name').notNull(),
description: text('description'),
fields: jsonb('fields').notNull(),
branding: jsonb('branding').default({}),
isActive: boolean('is_active').notNull().default(true),
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_ft_port').on(table.portId)],
);
export const formSubmissions = pgTable(
'form_submissions',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
formTemplateId: text('form_template_id')
.notNull()
.references(() => formTemplates.id),
clientId: text('client_id').references(() => clients.id),
interestId: text('interest_id'), // references interests.id
token: text('token').notNull().unique(),
prefilledData: jsonb('prefilled_data').default({}),
submittedData: jsonb('submitted_data'),
status: text('status').notNull().default('pending'), // pending, submitted, expired
expiresAt: timestamp('expires_at', { withTimezone: true }),
submittedAt: timestamp('submitted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [uniqueIndex('idx_fs_token').on(table.token)],
);
export type File = typeof files.$inferSelect;
export type NewFile = typeof files.$inferInsert;
export type Document = typeof documents.$inferSelect;
export type NewDocument = typeof documents.$inferInsert;
export type DocumentSigner = typeof documentSigners.$inferSelect;
export type NewDocumentSigner = typeof documentSigners.$inferInsert;
export type DocumentEvent = typeof documentEvents.$inferSelect;
export type NewDocumentEvent = typeof documentEvents.$inferInsert;
export type DocumentTemplate = typeof documentTemplates.$inferSelect;
export type NewDocumentTemplate = typeof documentTemplates.$inferInsert;
export type FormTemplate = typeof formTemplates.$inferSelect;
export type NewFormTemplate = typeof formTemplates.$inferInsert;
export type FormSubmission = typeof formSubmissions.$inferSelect;
export type NewFormSubmission = typeof formSubmissions.$inferInsert;

View File

@@ -0,0 +1,95 @@
import {
pgTable,
text,
boolean,
integer,
timestamp,
index,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { ports } from './ports';
import { clients } from './clients';
import { files } from './documents';
export const emailAccounts = pgTable(
'email_accounts',
{
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),
provider: text('provider').notNull(), // google, outlook, custom
emailAddress: text('email_address').notNull(),
smtpHost: text('smtp_host').notNull(),
smtpPort: integer('smtp_port').notNull(),
imapHost: text('imap_host').notNull(),
imapPort: integer('imap_port').notNull(),
// credentials_enc stored as base64-encoded text (encrypted at application layer)
credentialsEnc: text('credentials_enc').notNull(),
isActive: boolean('is_active').notNull().default(true),
lastSyncAt: timestamp('last_sync_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_ea_user').on(table.userId),
index('idx_ea_port').on(table.portId),
],
);
export const emailThreads = pgTable(
'email_threads',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
clientId: text('client_id').references(() => clients.id),
subject: text('subject'),
lastMessageAt: timestamp('last_message_at', { withTimezone: true }),
messageCount: integer('message_count').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_et_client').on(table.clientId),
index('idx_et_port').on(table.portId),
],
);
export const emailMessages = pgTable(
'email_messages',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
threadId: text('thread_id')
.notNull()
.references(() => emailThreads.id, { onDelete: 'cascade' }),
messageIdHeader: text('message_id_header'), // email Message-ID header
fromAddress: text('from_address').notNull(),
toAddresses: text('to_addresses').array().notNull(),
ccAddresses: text('cc_addresses').array(),
subject: text('subject'),
bodyText: text('body_text'),
bodyHtml: text('body_html'),
direction: text('direction').notNull(), // inbound, outbound
sentAt: timestamp('sent_at', { withTimezone: true }).notNull(),
attachmentFileIds: text('attachment_file_ids').array(), // references to files table
rawFileId: text('raw_file_id').references(() => files.id),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_em_thread').on(table.threadId),
uniqueIndex('idx_em_message_id').on(table.messageIdHeader).where(
sql`${table.messageIdHeader} IS NOT NULL`
),
],
);
export type EmailAccount = typeof emailAccounts.$inferSelect;
export type NewEmailAccount = typeof emailAccounts.$inferInsert;
export type EmailThread = typeof emailThreads.$inferSelect;
export type NewEmailThread = typeof emailThreads.$inferInsert;
export type EmailMessage = typeof emailMessages.$inferSelect;
export type NewEmailMessage = typeof emailMessages.$inferInsert;

View File

@@ -0,0 +1,125 @@
import {
pgTable,
text,
numeric,
integer,
timestamp,
date,
index,
uniqueIndex,
primaryKey,
} from 'drizzle-orm/pg-core';
import { ports } from './ports';
import { files } from './documents';
export const expenses = pgTable(
'expenses',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
establishmentName: text('establishment_name'),
amount: numeric('amount').notNull(),
currency: text('currency').notNull().default('USD'),
amountUsd: numeric('amount_usd'),
exchangeRate: numeric('exchange_rate'),
paymentMethod: text('payment_method'),
category: text('category'),
payer: text('payer'),
expenseDate: timestamp('expense_date', { withTimezone: true }).notNull(),
description: text('description'),
receiptFileIds: text('receipt_file_ids').array(), // references to files table
paymentStatus: text('payment_status').default('unpaid'), // unpaid, paid, partial
paymentDate: date('payment_date'),
paymentReference: text('payment_reference'),
paymentNotes: text('payment_notes'),
createdBy: text('created_by').notNull(),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_expenses_port').on(table.portId),
index('idx_expenses_date').on(table.portId, table.expenseDate),
index('idx_expenses_category').on(table.portId, table.category),
],
);
export const invoices = pgTable(
'invoices',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
invoiceNumber: text('invoice_number').notNull(), // INV-YYYYMM-### auto-generated
clientName: text('client_name').notNull(),
billingEmail: text('billing_email'),
billingAddress: text('billing_address'),
dueDate: date('due_date').notNull(),
paymentTerms: text('payment_terms').notNull().default('net30'), // immediate, net10, net15, net30, net45, net60
currency: text('currency').notNull().default('USD'),
subtotal: numeric('subtotal').notNull(),
discountPct: numeric('discount_pct').default('0'),
discountAmount: numeric('discount_amount').default('0'),
feePct: numeric('fee_pct').default('0'),
feeAmount: numeric('fee_amount').default('0'),
total: numeric('total').notNull(),
status: text('status').notNull().default('draft'), // draft, sent, paid, overdue, cancelled
paymentStatus: text('payment_status').default('unpaid'),
paymentDate: date('payment_date'),
paymentMethod: text('payment_method'),
paymentReference: text('payment_reference'),
pdfFileId: text('pdf_file_id').references(() => files.id),
notes: text('notes'),
createdBy: text('created_by').notNull(),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('idx_invoices_number').on(table.portId, table.invoiceNumber),
index('idx_invoices_port').on(table.portId),
index('idx_invoices_status').on(table.portId, table.status),
],
);
export const invoiceLineItems = pgTable(
'invoice_line_items',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
invoiceId: text('invoice_id')
.notNull()
.references(() => invoices.id, { onDelete: 'cascade' }),
description: text('description').notNull(),
quantity: numeric('quantity').notNull().default('1'),
unitPrice: numeric('unit_price').notNull(),
total: numeric('total').notNull(),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_ili_invoice').on(table.invoiceId)],
);
export const invoiceExpenses = pgTable(
'invoice_expenses',
{
invoiceId: text('invoice_id')
.notNull()
.references(() => invoices.id, { onDelete: 'cascade' }),
expenseId: text('expense_id')
.notNull()
.references(() => expenses.id, { onDelete: 'cascade' }),
},
(table) => [primaryKey({ columns: [table.invoiceId, table.expenseId] })],
);
export type Expense = typeof expenses.$inferSelect;
export type NewExpense = typeof expenses.$inferInsert;
export type Invoice = typeof invoices.$inferSelect;
export type NewInvoice = typeof invoices.$inferInsert;
export type InvoiceLineItem = typeof invoiceLineItems.$inferSelect;
export type NewInvoiceLineItem = typeof invoiceLineItems.$inferInsert;
export type InvoiceExpense = typeof invoiceExpenses.$inferSelect;
export type NewInvoiceExpense = typeof invoiceExpenses.$inferInsert;

View File

@@ -0,0 +1,32 @@
// Ports
export * from './ports';
// Users & Auth
export * from './users';
// Clients
export * from './clients';
// Interests
export * from './interests';
// Berths
export * from './berths';
// Documents & Files
export * from './documents';
// Financial
export * from './financial';
// Email
export * from './email';
// Operations
export * from './operations';
// System
export * from './system';
// Relations (must come last — references all tables)
export * from './relations';

View File

@@ -0,0 +1,89 @@
import {
pgTable,
text,
boolean,
integer,
timestamp,
primaryKey,
index,
} from 'drizzle-orm/pg-core';
import { ports } from './ports';
import { clients } from './clients';
// Pipeline stages: open, details_sent, in_communication, visited, signed_eoi_nda, deposit_10pct, contract, completed
export const interests = pgTable(
'interests',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
clientId: text('client_id')
.notNull()
.references(() => clients.id),
berthId: text('berth_id'), // nullable — FK to berths defined in berths.ts, added via relation
pipelineStage: text('pipeline_stage').notNull().default('open'),
leadCategory: text('lead_category'), // general_interest, specific_qualified, hot_lead
source: text('source'), // website, manual, referral, broker
eoiStatus: text('eoi_status'), // null, waiting_for_signatures, signed, expired
documensoId: text('documenso_id'),
contractStatus: text('contract_status'),
depositStatus: text('deposit_status'),
reservationStatus: text('reservation_status'),
dateFirstContact: timestamp('date_first_contact', { withTimezone: true }),
dateLastContact: timestamp('date_last_contact', { withTimezone: true }),
dateEoiSent: timestamp('date_eoi_sent', { withTimezone: true }),
dateEoiSigned: timestamp('date_eoi_signed', { withTimezone: true }),
dateContractSent: timestamp('date_contract_sent', { withTimezone: true }),
dateContractSigned: timestamp('date_contract_signed', { withTimezone: true }),
dateDepositReceived: timestamp('date_deposit_received', { withTimezone: true }),
reminderEnabled: boolean('reminder_enabled').notNull().default(false),
reminderDays: integer('reminder_days'),
reminderLastFired: timestamp('reminder_last_fired', { withTimezone: true }),
notes: text('notes'),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_interests_port').on(table.portId),
index('idx_interests_client').on(table.clientId),
index('idx_interests_berth').on(table.berthId),
index('idx_interests_stage').on(table.portId, table.pipelineStage),
index('idx_interests_archived').on(table.portId, table.archivedAt),
],
);
export const interestNotes = pgTable(
'interest_notes',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
interestId: text('interest_id')
.notNull()
.references(() => interests.id, { onDelete: 'cascade' }),
authorId: text('author_id').notNull(), // user ID
content: text('content').notNull(),
mentions: text('mentions').array(), // array of mentioned user IDs
isLocked: boolean('is_locked').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_in_interest').on(table.interestId)],
);
export const interestTags = pgTable(
'interest_tags',
{
interestId: text('interest_id')
.notNull()
.references(() => interests.id, { onDelete: 'cascade' }),
tagId: text('tag_id').notNull(), // references tags.id
},
(table) => [primaryKey({ columns: [table.interestId, table.tagId] })],
);
export type Interest = typeof interests.$inferSelect;
export type NewInterest = typeof interests.$inferInsert;
export type InterestNote = typeof interestNotes.$inferSelect;
export type NewInterestNote = typeof interestNotes.$inferInsert;

View File

@@ -0,0 +1,193 @@
import {
pgTable,
text,
boolean,
timestamp,
jsonb,
index,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { ports } from './ports';
import { clients } from './clients';
import { files } from './documents';
export const reminders = pgTable(
'reminders',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
title: text('title').notNull(),
note: text('note'),
dueAt: timestamp('due_at', { withTimezone: true }).notNull(),
priority: text('priority').notNull().default('medium'), // low, medium, high, urgent
status: text('status').notNull().default('pending'), // pending, snoozed, completed, dismissed
assignedTo: text('assigned_to'), // user ID
createdBy: text('created_by').notNull(),
clientId: text('client_id').references(() => clients.id),
interestId: text('interest_id'), // references interests.id
berthId: text('berth_id'), // references berths.id
autoGenerated: boolean('auto_generated').notNull().default(false),
googleCalendarEventId: text('google_calendar_event_id'),
googleCalendarSynced: boolean('google_calendar_synced').notNull().default(false),
snoozedUntil: timestamp('snoozed_until', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_reminders_port').on(table.portId),
index('idx_reminders_assigned').on(table.assignedTo, table.status),
index('idx_reminders_due').on(table.portId, table.dueAt).where(
sql`${table.status} IN ('pending', 'snoozed')`
),
],
);
export const googleCalendarTokens = pgTable(
'google_calendar_tokens',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').notNull().unique(),
accessToken: text('access_token').notNull(), // encrypted
refreshToken: text('refresh_token').notNull(), // encrypted
tokenExpiry: timestamp('token_expiry', { withTimezone: true }).notNull(),
calendarId: text('calendar_id').notNull().default('primary'),
connectedAt: timestamp('connected_at', { withTimezone: true }).notNull().defaultNow(),
lastSyncAt: timestamp('last_sync_at', { withTimezone: true }),
syncEnabled: boolean('sync_enabled').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [uniqueIndex('gcal_tokens_user_id_idx').on(table.userId)],
);
export const googleCalendarCache = pgTable(
'google_calendar_cache',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').notNull(),
eventId: text('event_id').notNull(), // Google Calendar event ID
title: text('title').notNull(),
startAt: timestamp('start_at', { withTimezone: true }).notNull(),
endAt: timestamp('end_at', { withTimezone: true }),
location: text('location'),
description: text('description'),
isCrmPushed: boolean('is_crm_pushed').notNull().default(false),
reminderId: text('reminder_id').references(() => reminders.id),
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('gcal_cache_user_event_idx').on(table.userId, table.eventId),
index('idx_gcal_cache_user').on(table.userId, table.startAt),
],
);
export const notifications = pgTable(
'notifications',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
userId: text('user_id').notNull(),
type: text('type').notNull(), // reminder_due, reminder_overdue, new_registration, eoi_signed, eoi_completed, email_received, duplicate_alert, invoice_overdue, waiting_list, system_alert, follow_up_created, tenure_expiring
title: text('title').notNull(),
description: text('description'),
link: text('link'),
entityType: text('entity_type'), // client, interest, berth, invoice, etc.
entityId: text('entity_id'),
isRead: boolean('is_read').notNull().default(false),
emailSent: boolean('email_sent').notNull().default(false),
metadata: jsonb('metadata').$type<Record<string, unknown>>().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_notif_user').on(table.userId, table.isRead),
index('idx_notif_port').on(table.portId),
index('idx_notifications_user_type').on(table.userId, table.type, table.createdAt),
],
);
export const scheduledReports = pgTable(
'scheduled_reports',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
name: text('name').notNull(),
reportType: text('report_type').notNull(), // pipeline_summary, expense_summary, berth_occupancy, activity_log, overdue_items, revenue_forecast
schedule: text('schedule').notNull(), // cron expression
lastRunAt: timestamp('last_run_at', { withTimezone: true }),
nextRunAt: timestamp('next_run_at', { withTimezone: true }),
isActive: boolean('is_active').notNull().default(true),
config: jsonb('config').default({}),
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_sr_port').on(table.portId)],
);
export const reportRecipients = pgTable(
'report_recipients',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
reportId: text('report_id')
.notNull()
.references(() => scheduledReports.id, { onDelete: 'cascade' }),
email: text('email').notNull(),
userId: text('user_id'), // null for external recipients
},
(table) => [
uniqueIndex('report_recipients_report_email_idx').on(table.reportId, table.email),
index('idx_rr_report').on(table.reportId),
],
);
export const generatedReports = pgTable(
'generated_reports',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
scheduledReportId: text('scheduled_report_id').references(() => scheduledReports.id),
reportType: text('report_type').notNull(),
name: text('name').notNull(),
status: text('status').notNull().default('queued'), // queued, processing, ready, failed
parameters: jsonb('parameters').default({}),
fileId: text('file_id').references(() => files.id),
errorMessage: text('error_message'),
requestedBy: text('requested_by').notNull(),
startedAt: timestamp('started_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_gr_port_created').on(table.portId, table.createdAt),
index('idx_gr_port_status').on(table.portId, table.status),
index('idx_gr_scheduled').on(table.scheduledReportId).where(
sql`${table.scheduledReportId} IS NOT NULL`
),
],
);
export type Reminder = typeof reminders.$inferSelect;
export type NewReminder = typeof reminders.$inferInsert;
export type GoogleCalendarToken = typeof googleCalendarTokens.$inferSelect;
export type NewGoogleCalendarToken = typeof googleCalendarTokens.$inferInsert;
export type GoogleCalendarCache = typeof googleCalendarCache.$inferSelect;
export type NewGoogleCalendarCache = typeof googleCalendarCache.$inferInsert;
export type Notification = typeof notifications.$inferSelect;
export type NewNotification = typeof notifications.$inferInsert;
export type ScheduledReport = typeof scheduledReports.$inferSelect;
export type NewScheduledReport = typeof scheduledReports.$inferInsert;
export type ReportRecipient = typeof reportRecipients.$inferSelect;
export type NewReportRecipient = typeof reportRecipients.$inferInsert;
export type GeneratedReport = typeof generatedReports.$inferSelect;
export type NewGeneratedReport = typeof generatedReports.$inferInsert;

View File

@@ -0,0 +1,50 @@
import { pgTable, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
// Port settings type for JSONB column
export type PortSettings = {
berth_status_rules?: Array<{
trigger: string;
mode: 'auto' | 'suggest' | 'off';
target_status: string;
}>;
follow_up_defaults?: {
reminder_days: number;
send_window_hours: number[];
cooldown_days: number;
};
eoi_reminder_settings?: {
schedule: string[];
cooldown_days: number;
send_window_hours: number[];
};
[key: string]: unknown;
};
export type PortBranding = {
logo_url?: string;
primary_color?: string;
secondary_color?: string;
font_family?: string;
[key: string]: unknown;
};
export const ports = pgTable(
'ports',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text('name').notNull(),
slug: text('slug').notNull(),
logoUrl: text('logo_url'),
primaryColor: text('primary_color'),
defaultCurrency: text('default_currency').notNull().default('USD'),
timezone: text('timezone').notNull().default('America/Anguilla'),
settings: jsonb('settings').$type<PortSettings>().notNull().default({}),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [uniqueIndex('ports_slug_idx').on(table.slug)],
);
export type Port = typeof ports.$inferSelect;
export type NewPort = typeof ports.$inferInsert;

View File

@@ -0,0 +1,644 @@
import { relations } from 'drizzle-orm';
// Ports
import { ports } from './ports';
// Users
import { userProfiles, roles, portRoleOverrides, userPortRoles } from './users';
// Clients
import {
clients,
clientContacts,
clientRelationships,
clientNotes,
clientTags,
clientMergeLog,
} from './clients';
// Interests
import { interests, interestNotes, interestTags } from './interests';
// Berths
import {
berths,
berthMapData,
berthRecommendations,
berthWaitingList,
berthMaintenanceLog,
berthTags,
} from './berths';
// Documents
import {
files,
documents,
documentSigners,
documentEvents,
documentTemplates,
formTemplates,
formSubmissions,
} from './documents';
// Financial
import { expenses, invoices, invoiceLineItems, invoiceExpenses } from './financial';
// Email
import { emailAccounts, emailThreads, emailMessages } from './email';
// Operations
import {
reminders,
googleCalendarCache,
googleCalendarTokens,
notifications,
scheduledReports,
reportRecipients,
generatedReports,
} from './operations';
// System
import {
auditLogs,
tags,
webhooks,
webhookDeliveries,
systemSettings,
savedViews,
scratchpadNotes,
userNotificationPreferences,
currencyRates,
customFieldDefinitions,
customFieldValues,
} from './system';
// ─── Ports ────────────────────────────────────────────────────────────────────
export const portsRelations = relations(ports, ({ many }) => ({
userPortRoles: many(userPortRoles),
portRoleOverrides: many(portRoleOverrides),
clients: many(clients),
interests: many(interests),
berths: many(berths),
documents: many(documents),
documentTemplates: many(documentTemplates),
formTemplates: many(formTemplates),
expenses: many(expenses),
invoices: many(invoices),
emailAccounts: many(emailAccounts),
emailThreads: many(emailThreads),
reminders: many(reminders),
notifications: many(notifications),
scheduledReports: many(scheduledReports),
auditLogs: many(auditLogs),
tags: many(tags),
files: many(files),
webhooks: many(webhooks),
systemSettings: many(systemSettings),
savedViews: many(savedViews),
userNotificationPreferences: many(userNotificationPreferences),
customFieldDefinitions: many(customFieldDefinitions),
berthMaintenanceLogs: many(berthMaintenanceLog),
clientMergeLogs: many(clientMergeLog),
clientRelationships: many(clientRelationships),
}));
// ─── Users ────────────────────────────────────────────────────────────────────
export const userProfilesRelations = relations(userProfiles, ({ many }) => ({
userPortRoles: many(userPortRoles),
}));
export const rolesRelations = relations(roles, ({ many }) => ({
userPortRoles: many(userPortRoles),
portRoleOverrides: many(portRoleOverrides),
}));
export const portRoleOverridesRelations = relations(portRoleOverrides, ({ one }) => ({
port: one(ports, {
fields: [portRoleOverrides.portId],
references: [ports.id],
}),
role: one(roles, {
fields: [portRoleOverrides.roleId],
references: [roles.id],
}),
}));
export const userPortRolesRelations = relations(userPortRoles, ({ one }) => ({
port: one(ports, {
fields: [userPortRoles.portId],
references: [ports.id],
}),
role: one(roles, {
fields: [userPortRoles.roleId],
references: [roles.id],
}),
}));
// ─── Clients ──────────────────────────────────────────────────────────────────
export const clientsRelations = relations(clients, ({ one, many }) => ({
port: one(ports, {
fields: [clients.portId],
references: [ports.id],
}),
contacts: many(clientContacts),
notes: many(clientNotes),
tags: many(clientTags),
interests: many(interests),
relationships_a: many(clientRelationships, { relationName: 'client_a' }),
relationships_b: many(clientRelationships, { relationName: 'client_b' }),
mergeLogsAsSurvivor: many(clientMergeLog),
documents: many(documents),
emailThreads: many(emailThreads),
reminders: many(reminders),
files: many(files),
waitingListEntries: many(berthWaitingList),
scratchpadNotes: many(scratchpadNotes),
formSubmissions: many(formSubmissions),
}));
export const clientContactsRelations = relations(clientContacts, ({ one }) => ({
client: one(clients, {
fields: [clientContacts.clientId],
references: [clients.id],
}),
}));
export const clientRelationshipsRelations = relations(clientRelationships, ({ one }) => ({
port: one(ports, {
fields: [clientRelationships.portId],
references: [ports.id],
}),
clientA: one(clients, {
fields: [clientRelationships.clientAId],
references: [clients.id],
relationName: 'client_a',
}),
clientB: one(clients, {
fields: [clientRelationships.clientBId],
references: [clients.id],
relationName: 'client_b',
}),
}));
export const clientNotesRelations = relations(clientNotes, ({ one }) => ({
client: one(clients, {
fields: [clientNotes.clientId],
references: [clients.id],
}),
}));
export const clientTagsRelations = relations(clientTags, ({ one }) => ({
client: one(clients, {
fields: [clientTags.clientId],
references: [clients.id],
}),
tag: one(tags, {
fields: [clientTags.tagId],
references: [tags.id],
}),
}));
export const clientMergeLogRelations = relations(clientMergeLog, ({ one }) => ({
port: one(ports, {
fields: [clientMergeLog.portId],
references: [ports.id],
}),
survivingClient: one(clients, {
fields: [clientMergeLog.survivingClientId],
references: [clients.id],
}),
}));
// ─── Interests ────────────────────────────────────────────────────────────────
export const interestsRelations = relations(interests, ({ one, many }) => ({
port: one(ports, {
fields: [interests.portId],
references: [ports.id],
}),
client: one(clients, {
fields: [interests.clientId],
references: [clients.id],
}),
berth: one(berths, {
fields: [interests.berthId],
references: [berths.id],
}),
notes: many(interestNotes),
tags: many(interestTags),
documents: many(documents),
reminders: many(reminders),
berthRecommendations: many(berthRecommendations),
formSubmissions: many(formSubmissions),
}));
export const interestNotesRelations = relations(interestNotes, ({ one }) => ({
interest: one(interests, {
fields: [interestNotes.interestId],
references: [interests.id],
}),
}));
export const interestTagsRelations = relations(interestTags, ({ one }) => ({
interest: one(interests, {
fields: [interestTags.interestId],
references: [interests.id],
}),
tag: one(tags, {
fields: [interestTags.tagId],
references: [tags.id],
}),
}));
// ─── Berths ───────────────────────────────────────────────────────────────────
export const berthsRelations = relations(berths, ({ one, many }) => ({
port: one(ports, {
fields: [berths.portId],
references: [ports.id],
}),
mapData: one(berthMapData),
recommendations: many(berthRecommendations),
waitingList: many(berthWaitingList),
maintenanceLogs: many(berthMaintenanceLog),
tags: many(berthTags),
interests: many(interests),
reminders: many(reminders),
}));
export const berthMapDataRelations = relations(berthMapData, ({ one }) => ({
berth: one(berths, {
fields: [berthMapData.berthId],
references: [berths.id],
}),
}));
export const berthRecommendationsRelations = relations(berthRecommendations, ({ one }) => ({
interest: one(interests, {
fields: [berthRecommendations.interestId],
references: [interests.id],
}),
berth: one(berths, {
fields: [berthRecommendations.berthId],
references: [berths.id],
}),
}));
export const berthWaitingListRelations = relations(berthWaitingList, ({ one }) => ({
berth: one(berths, {
fields: [berthWaitingList.berthId],
references: [berths.id],
}),
client: one(clients, {
fields: [berthWaitingList.clientId],
references: [clients.id],
}),
}));
export const berthMaintenanceLogRelations = relations(berthMaintenanceLog, ({ one }) => ({
berth: one(berths, {
fields: [berthMaintenanceLog.berthId],
references: [berths.id],
}),
port: one(ports, {
fields: [berthMaintenanceLog.portId],
references: [ports.id],
}),
}));
export const berthTagsRelations = relations(berthTags, ({ one }) => ({
berth: one(berths, {
fields: [berthTags.berthId],
references: [berths.id],
}),
tag: one(tags, {
fields: [berthTags.tagId],
references: [tags.id],
}),
}));
// ─── Documents ────────────────────────────────────────────────────────────────
export const filesRelations = relations(files, ({ one, many }) => ({
port: one(ports, {
fields: [files.portId],
references: [ports.id],
}),
client: one(clients, {
fields: [files.clientId],
references: [clients.id],
}),
documentAsFile: many(documents, { relationName: 'file' }),
documentAsSignedFile: many(documents, { relationName: 'signed_file' }),
}));
export const documentsRelations = relations(documents, ({ one, many }) => ({
port: one(ports, {
fields: [documents.portId],
references: [ports.id],
}),
interest: one(interests, {
fields: [documents.interestId],
references: [interests.id],
}),
client: one(clients, {
fields: [documents.clientId],
references: [clients.id],
}),
file: one(files, {
fields: [documents.fileId],
references: [files.id],
relationName: 'file',
}),
signedFile: one(files, {
fields: [documents.signedFileId],
references: [files.id],
relationName: 'signed_file',
}),
signers: many(documentSigners),
events: many(documentEvents),
}));
export const documentSignersRelations = relations(documentSigners, ({ one, many }) => ({
document: one(documents, {
fields: [documentSigners.documentId],
references: [documents.id],
}),
events: many(documentEvents),
}));
export const documentEventsRelations = relations(documentEvents, ({ one }) => ({
document: one(documents, {
fields: [documentEvents.documentId],
references: [documents.id],
}),
signer: one(documentSigners, {
fields: [documentEvents.signerId],
references: [documentSigners.id],
}),
}));
export const documentTemplatesRelations = relations(documentTemplates, ({ one }) => ({
port: one(ports, {
fields: [documentTemplates.portId],
references: [ports.id],
}),
}));
export const formTemplatesRelations = relations(formTemplates, ({ one, many }) => ({
port: one(ports, {
fields: [formTemplates.portId],
references: [ports.id],
}),
submissions: many(formSubmissions),
}));
export const formSubmissionsRelations = relations(formSubmissions, ({ one }) => ({
formTemplate: one(formTemplates, {
fields: [formSubmissions.formTemplateId],
references: [formTemplates.id],
}),
client: one(clients, {
fields: [formSubmissions.clientId],
references: [clients.id],
}),
interest: one(interests, {
fields: [formSubmissions.interestId],
references: [interests.id],
}),
}));
// ─── Financial ────────────────────────────────────────────────────────────────
export const expensesRelations = relations(expenses, ({ one, many }) => ({
port: one(ports, {
fields: [expenses.portId],
references: [ports.id],
}),
invoiceExpenses: many(invoiceExpenses),
}));
export const invoicesRelations = relations(invoices, ({ one, many }) => ({
port: one(ports, {
fields: [invoices.portId],
references: [ports.id],
}),
pdfFile: one(files, {
fields: [invoices.pdfFileId],
references: [files.id],
}),
lineItems: many(invoiceLineItems),
invoiceExpenses: many(invoiceExpenses),
}));
export const invoiceLineItemsRelations = relations(invoiceLineItems, ({ one }) => ({
invoice: one(invoices, {
fields: [invoiceLineItems.invoiceId],
references: [invoices.id],
}),
}));
export const invoiceExpensesRelations = relations(invoiceExpenses, ({ one }) => ({
invoice: one(invoices, {
fields: [invoiceExpenses.invoiceId],
references: [invoices.id],
}),
expense: one(expenses, {
fields: [invoiceExpenses.expenseId],
references: [expenses.id],
}),
}));
// ─── Email ────────────────────────────────────────────────────────────────────
export const emailAccountsRelations = relations(emailAccounts, ({ one }) => ({
port: one(ports, {
fields: [emailAccounts.portId],
references: [ports.id],
}),
}));
export const emailThreadsRelations = relations(emailThreads, ({ one, many }) => ({
port: one(ports, {
fields: [emailThreads.portId],
references: [ports.id],
}),
client: one(clients, {
fields: [emailThreads.clientId],
references: [clients.id],
}),
messages: many(emailMessages),
}));
export const emailMessagesRelations = relations(emailMessages, ({ one }) => ({
thread: one(emailThreads, {
fields: [emailMessages.threadId],
references: [emailThreads.id],
}),
rawFile: one(files, {
fields: [emailMessages.rawFileId],
references: [files.id],
}),
}));
// ─── Operations ───────────────────────────────────────────────────────────────
export const remindersRelations = relations(reminders, ({ one, many }) => ({
port: one(ports, {
fields: [reminders.portId],
references: [ports.id],
}),
client: one(clients, {
fields: [reminders.clientId],
references: [clients.id],
}),
interest: one(interests, {
fields: [reminders.interestId],
references: [interests.id],
}),
berth: one(berths, {
fields: [reminders.berthId],
references: [berths.id],
}),
calendarCacheEntries: many(googleCalendarCache),
}));
export const googleCalendarTokensRelations = relations(googleCalendarTokens, ({ many }) => ({
cacheEntries: many(googleCalendarCache),
}));
export const googleCalendarCacheRelations = relations(googleCalendarCache, ({ one }) => ({
reminder: one(reminders, {
fields: [googleCalendarCache.reminderId],
references: [reminders.id],
}),
}));
export const notificationsRelations = relations(notifications, ({ one }) => ({
port: one(ports, {
fields: [notifications.portId],
references: [ports.id],
}),
}));
export const scheduledReportsRelations = relations(scheduledReports, ({ one, many }) => ({
port: one(ports, {
fields: [scheduledReports.portId],
references: [ports.id],
}),
recipients: many(reportRecipients),
generatedReports: many(generatedReports),
}));
export const reportRecipientsRelations = relations(reportRecipients, ({ one }) => ({
report: one(scheduledReports, {
fields: [reportRecipients.reportId],
references: [scheduledReports.id],
}),
}));
export const generatedReportsRelations = relations(generatedReports, ({ one }) => ({
port: one(ports, {
fields: [generatedReports.portId],
references: [ports.id],
}),
scheduledReport: one(scheduledReports, {
fields: [generatedReports.scheduledReportId],
references: [scheduledReports.id],
}),
file: one(files, {
fields: [generatedReports.fileId],
references: [files.id],
}),
}));
// ─── System ───────────────────────────────────────────────────────────────────
export const auditLogsRelations = relations(auditLogs, ({ one }) => ({
port: one(ports, {
fields: [auditLogs.portId],
references: [ports.id],
}),
revertOfLog: one(auditLogs, {
fields: [auditLogs.revertOf],
references: [auditLogs.id],
relationName: 'revert_of',
}),
}));
export const tagsRelations = relations(tags, ({ one, many }) => ({
port: one(ports, {
fields: [tags.portId],
references: [ports.id],
}),
clientTags: many(clientTags),
interestTags: many(interestTags),
berthTags: many(berthTags),
}));
export const webhooksRelations = relations(webhooks, ({ one, many }) => ({
port: one(ports, {
fields: [webhooks.portId],
references: [ports.id],
}),
deliveries: many(webhookDeliveries),
}));
export const webhookDeliveriesRelations = relations(webhookDeliveries, ({ one }) => ({
webhook: one(webhooks, {
fields: [webhookDeliveries.webhookId],
references: [webhooks.id],
}),
}));
export const systemSettingsRelations = relations(systemSettings, ({ one }) => ({
port: one(ports, {
fields: [systemSettings.portId],
references: [ports.id],
}),
}));
export const savedViewsRelations = relations(savedViews, ({ one }) => ({
port: one(ports, {
fields: [savedViews.portId],
references: [ports.id],
}),
}));
export const scratchpadNotesRelations = relations(scratchpadNotes, ({ one }) => ({
linkedClient: one(clients, {
fields: [scratchpadNotes.linkedClientId],
references: [clients.id],
}),
}));
export const userNotificationPreferencesRelations = relations(
userNotificationPreferences,
({ one }) => ({
port: one(ports, {
fields: [userNotificationPreferences.portId],
references: [ports.id],
}),
}),
);
export const customFieldDefinitionsRelations = relations(
customFieldDefinitions,
({ one, many }) => ({
port: one(ports, {
fields: [customFieldDefinitions.portId],
references: [ports.id],
}),
values: many(customFieldValues),
}),
);
export const customFieldValuesRelations = relations(customFieldValues, ({ one }) => ({
definition: one(customFieldDefinitions, {
fields: [customFieldValues.fieldId],
references: [customFieldDefinitions.id],
}),
}));

243
src/lib/db/schema/system.ts Normal file
View File

@@ -0,0 +1,243 @@
import {
pgTable,
text,
boolean,
integer,
numeric,
timestamp,
jsonb,
index,
uniqueIndex,
primaryKey,
} from 'drizzle-orm/pg-core';
import { ports } from './ports';
import { clients } from './clients';
export const auditLogs = pgTable(
'audit_logs',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id').references(() => ports.id), // null for system-level events
userId: text('user_id'), // null for system-generated events
action: text('action').notNull(), // create, update, delete, archive, restore, merge, login, logout, revert
entityType: text('entity_type').notNull(), // client, interest, berth, expense, invoice, file, user, role, etc.
entityId: text('entity_id'),
fieldChanged: text('field_changed'),
oldValue: jsonb('old_value'),
newValue: jsonb('new_value'),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
revertedBy: text('reverted_by'), // user ID if this change was reverted
revertedAt: timestamp('reverted_at', { withTimezone: true }),
revertOf: text('revert_of').references((): any => auditLogs.id),
metadata: jsonb('metadata').default({}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_al_port').on(table.portId, table.createdAt),
index('idx_al_entity').on(table.entityType, table.entityId),
index('idx_al_user').on(table.userId, table.createdAt),
index('idx_al_created').on(table.createdAt),
],
);
export const tags = pgTable(
'tags',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
name: text('name').notNull(),
color: text('color').notNull().default('#6B7280'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('tags_port_name_idx').on(table.portId, table.name),
index('idx_tags_port').on(table.portId),
],
);
export const webhooks = pgTable(
'webhooks',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
name: text('name').notNull(),
url: text('url').notNull(),
secret: text('secret'),
events: text('events').array().notNull(),
isActive: boolean('is_active').notNull().default(true),
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_webhooks_port').on(table.portId)],
);
export const webhookDeliveries = pgTable(
'webhook_deliveries',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
webhookId: text('webhook_id')
.notNull()
.references(() => webhooks.id, { onDelete: 'cascade' }),
eventType: text('event_type').notNull(),
payload: jsonb('payload').notNull(),
responseStatus: integer('response_status'),
responseBody: text('response_body'),
attempt: integer('attempt').notNull().default(1),
status: text('status').notNull().default('pending'), // pending, success, failed, dead_letter
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_wd_webhook').on(table.webhookId, table.createdAt)],
);
export const systemSettings = pgTable(
'system_settings',
{
key: text('key').notNull(),
value: jsonb('value').notNull(),
portId: text('port_id').references(() => ports.id), // null for global settings
updatedBy: text('updated_by'),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('system_settings_key_port_idx').on(table.key, table.portId),
// Note: the PRIMARY KEY is `key` alone based on schema, but unique on (key, port_id)
// We use key as primary key per SQL schema
],
);
export const savedViews = pgTable(
'saved_views',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
userId: text('user_id').notNull(),
entityType: text('entity_type').notNull(), // clients, interests, berths, expenses, invoices
name: text('name').notNull(),
filters: jsonb('filters').notNull(),
sortConfig: jsonb('sort_config'),
columnConfig: jsonb('column_config'),
isShared: boolean('is_shared').notNull().default(false),
isDefault: boolean('is_default').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_sv_user').on(table.userId, table.entityType)],
);
export const scratchpadNotes = pgTable(
'scratchpad_notes',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').notNull(),
content: text('content').notNull(),
linkedClientId: text('linked_client_id').references(() => clients.id),
linkedAt: timestamp('linked_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_sp_user').on(table.userId)],
);
export const userNotificationPreferences = pgTable(
'user_notification_preferences',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').notNull(),
portId: text('port_id')
.notNull()
.references(() => ports.id),
notificationType: text('notification_type').notNull(),
inApp: boolean('in_app').notNull().default(true),
email: boolean('email').notNull().default(true),
},
(table) => [
uniqueIndex('unp_user_port_type_idx').on(table.userId, table.portId, table.notificationType),
],
);
export const currencyRates = pgTable(
'currency_rates',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
baseCurrency: text('base_currency').notNull(),
targetCurrency: text('target_currency').notNull(),
rate: numeric('rate').notNull(),
source: text('source').notNull().default('frankfurter'), // frankfurter, manual
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('currency_rates_base_target_idx').on(table.baseCurrency, table.targetCurrency),
],
);
export const customFieldDefinitions = pgTable(
'custom_field_definitions',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
entityType: text('entity_type').notNull(), // client, interest, berth
fieldName: text('field_name').notNull(),
fieldLabel: text('field_label').notNull(),
fieldType: text('field_type').notNull(), // text, number, date, boolean, select
selectOptions: jsonb('select_options'), // for select type: ["option1", "option2"]
isRequired: boolean('is_required').notNull().default(false),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('cfd_port_entity_name_idx').on(table.portId, table.entityType, table.fieldName),
index('idx_cfd_port').on(table.portId),
],
);
export const customFieldValues = pgTable(
'custom_field_values',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
fieldId: text('field_id')
.notNull()
.references(() => customFieldDefinitions.id, { onDelete: 'cascade' }),
entityId: text('entity_id').notNull(), // references the client/interest/berth ID
value: jsonb('value').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('cfv_field_entity_idx').on(table.fieldId, table.entityId),
index('idx_cfv_entity').on(table.entityId),
],
);
export type AuditLog = typeof auditLogs.$inferSelect;
export type NewAuditLog = typeof auditLogs.$inferInsert;
export type Tag = typeof tags.$inferSelect;
export type NewTag = typeof tags.$inferInsert;
export type Webhook = typeof webhooks.$inferSelect;
export type NewWebhook = typeof webhooks.$inferInsert;
export type WebhookDelivery = typeof webhookDeliveries.$inferSelect;
export type NewWebhookDelivery = typeof webhookDeliveries.$inferInsert;
export type SystemSetting = typeof systemSettings.$inferSelect;
export type NewSystemSetting = typeof systemSettings.$inferInsert;
export type SavedView = typeof savedViews.$inferSelect;
export type NewSavedView = typeof savedViews.$inferInsert;
export type ScratchpadNote = typeof scratchpadNotes.$inferSelect;
export type NewScratchpadNote = typeof scratchpadNotes.$inferInsert;
export type UserNotificationPreference = typeof userNotificationPreferences.$inferSelect;
export type NewUserNotificationPreference = typeof userNotificationPreferences.$inferInsert;
export type CurrencyRate = typeof currencyRates.$inferSelect;
export type NewCurrencyRate = typeof currencyRates.$inferInsert;
export type CustomFieldDefinition = typeof customFieldDefinitions.$inferSelect;
export type NewCustomFieldDefinition = typeof customFieldDefinitions.$inferInsert;
export type CustomFieldValue = typeof customFieldValues.$inferSelect;
export type NewCustomFieldValue = typeof customFieldValues.$inferInsert;

265
src/lib/db/schema/users.ts Normal file
View File

@@ -0,0 +1,265 @@
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;
generate_eoi: boolean;
export: boolean;
};
berths: {
view: boolean;
edit: boolean;
import: boolean;
manage_waiting_list: boolean;
};
documents: {
view: boolean;
create: boolean;
send_for_signing: boolean;
upload_signed: boolean;
delete: 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;
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;
};
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;
};
};
export type UserPreferences = {
dark_mode?: boolean;
locale?: string;
timezone?: 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
displayName: text('display_name').notNull(),
avatarUrl: text('avatar_url'),
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(),
});
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),
],
);
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' }),
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;

219
src/lib/db/seed.ts Normal file
View File

@@ -0,0 +1,219 @@
/**
* Seed script for Port Nimara CRM.
*
* Seeds:
* - 1 Port: Port Nimara
* - 5 System roles with full permission maps
* - 1 Super admin user profile (matt@portnimara.com)
*
* Run with: npm run db:seed
*/
import 'dotenv/config';
import { db } from './index';
import { ports } from './schema/ports';
import { roles, userProfiles } from './schema/users';
import type { RolePermissions } from './schema/users';
// ─── Permission Maps ─────────────────────────────────────────────────────────
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 },
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 },
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 },
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 },
};
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 },
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 },
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 },
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 },
};
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 },
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 },
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 },
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 },
};
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 },
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 },
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 },
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 },
};
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 },
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 },
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 },
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 },
};
// ─── Seed Function ────────────────────────────────────────────────────────────
async function seed() {
console.log('Seeding Port Nimara CRM...');
// ── 1. Port ─────────────────────────────────────────────────────────────────
console.log('Creating Port Nimara...');
const [port] = await db
.insert(ports)
.values({
id: crypto.randomUUID(),
name: 'Port Nimara',
slug: 'port-nimara',
logoUrl: null,
primaryColor: '#0F4C81',
defaultCurrency: 'USD',
timezone: 'America/Anguilla',
settings: {},
isActive: true,
})
.onConflictDoNothing()
.returning();
const portId = port?.id;
if (!portId) {
console.log('Port already exists, skipping...');
} else {
console.log(`Port created: ${portId}`);
}
// ── 2. System Roles ─────────────────────────────────────────────────────────
console.log('Creating system roles...');
const systemRoles = [
{
id: crypto.randomUUID(),
name: 'super_admin',
description: 'Full system access. Bypasses all permission checks.',
permissions: ALL_PERMISSIONS,
isGlobal: true,
isSystem: true,
},
{
id: crypto.randomUUID(),
name: 'director',
description: 'Operational admin within assigned port(s). Can manage users and settings.',
permissions: DIRECTOR_PERMISSIONS,
isGlobal: true,
isSystem: true,
},
{
id: crypto.randomUUID(),
name: 'sales_manager',
description: 'Full sales access. Can view all reminders, assign tasks, and export reports.',
permissions: SALES_MANAGER_PERMISSIONS,
isGlobal: true,
isSystem: true,
},
{
id: crypto.randomUUID(),
name: 'sales_agent',
description: 'Standard sales role. View/create/edit clients and interests, manage own reminders.',
permissions: SALES_AGENT_PERMISSIONS,
isGlobal: true,
isSystem: true,
},
{
id: crypto.randomUUID(),
name: 'viewer',
description: 'Read-only access to all records.',
permissions: VIEWER_PERMISSIONS,
isGlobal: true,
isSystem: true,
},
];
for (const role of systemRoles) {
await db.insert(roles).values(role).onConflictDoNothing();
console.log(`Role created: ${role.name}`);
}
// ── 3. Super Admin User Profile ─────────────────────────────────────────────
// Note: Better Auth creates the actual `user` record on first login.
// We create the profile extension now, linked to a known user_id.
// The Better Auth user_id for matt@portnimara.com must match this value
// once Better Auth is configured. Use a stable placeholder ID here.
console.log('Creating super admin user profile...');
const superAdminUserId = 'super-admin-matt-portnimara';
await db
.insert(userProfiles)
.values({
id: crypto.randomUUID(),
userId: superAdminUserId,
displayName: 'Matt',
avatarUrl: null,
phone: null,
isSuperAdmin: true,
isActive: true,
lastLoginAt: null,
preferences: {},
})
.onConflictDoNothing();
console.log(`Super admin profile created for user_id: ${superAdminUserId}`);
console.log('');
console.log('Seed complete!');
console.log('');
console.log('NOTE: The Better Auth user for matt@portnimara.com must be created');
console.log(`separately. Once created, update user_profiles.user_id to match`);
console.log(`the actual Better Auth user ID (currently placeholder: ${superAdminUserId})`);
process.exit(0);
}
seed().catch((err) => {
console.error('Seed failed:', err);
process.exit(1);
});

56
src/lib/db/utils.ts Normal file
View File

@@ -0,0 +1,56 @@
import { eq, sql } from 'drizzle-orm';
import type { PgTable, PgColumn } from 'drizzle-orm/pg-core';
import { db } from './index';
/**
* Wraps a database operation in a transaction.
* Rolls back automatically on error.
*
* @example
* const result = await withTransaction(async (tx) => {
* await tx.insert(clients).values({ ... });
* await tx.insert(interests).values({ ... });
* return result;
* });
*/
export async function withTransaction<T>(
callback: (tx: typeof db) => Promise<T>,
): Promise<T> {
return db.transaction(callback as any) as Promise<T>;
}
/**
* Soft-deletes a record by setting `archived_at` to now.
* The table must have an `archived_at` column.
*
* @example
* await softDelete(clients, clients.id, clientId);
*/
export async function softDelete<TTable extends PgTable>(
table: TTable,
idColumn: PgColumn,
id: string,
): Promise<void> {
await db
.update(table)
.set({ archived_at: sql`now()` } as any)
.where(eq(idColumn, id));
}
/**
* Restores a soft-deleted record by clearing `archived_at`.
* The table must have an `archived_at` column.
*
* @example
* await restore(clients, clients.id, clientId);
*/
export async function restore<TTable extends PgTable>(
table: TTable,
idColumn: PgColumn,
id: string,
): Promise<void> {
await db
.update(table)
.set({ archived_at: null } as any)
.where(eq(idColumn, id));
}