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