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:
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;
|
||||
Reference in New Issue
Block a user