Initial commit: Port Nimara CRM (Layers 0-4)
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:
20
src/lib/db/index.ts
Normal file
20
src/lib/db/index.ts
Normal 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
109
src/lib/db/query-builder.ts
Normal 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
178
src/lib/db/schema/berths.ts
Normal 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;
|
||||
150
src/lib/db/schema/clients.ts
Normal file
150
src/lib/db/schema/clients.ts
Normal 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;
|
||||
184
src/lib/db/schema/documents.ts
Normal file
184
src/lib/db/schema/documents.ts
Normal 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;
|
||||
95
src/lib/db/schema/email.ts
Normal file
95
src/lib/db/schema/email.ts
Normal 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;
|
||||
125
src/lib/db/schema/financial.ts
Normal file
125
src/lib/db/schema/financial.ts
Normal 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;
|
||||
32
src/lib/db/schema/index.ts
Normal file
32
src/lib/db/schema/index.ts
Normal 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';
|
||||
89
src/lib/db/schema/interests.ts
Normal file
89
src/lib/db/schema/interests.ts
Normal 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;
|
||||
193
src/lib/db/schema/operations.ts
Normal file
193
src/lib/db/schema/operations.ts
Normal 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;
|
||||
50
src/lib/db/schema/ports.ts
Normal file
50
src/lib/db/schema/ports.ts
Normal 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;
|
||||
644
src/lib/db/schema/relations.ts
Normal file
644
src/lib/db/schema/relations.ts
Normal 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
243
src/lib/db/schema/system.ts
Normal 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
265
src/lib/db/schema/users.ts
Normal 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
219
src/lib/db/seed.ts
Normal 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
56
src/lib/db/utils.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user