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), // Numeric: ft (legacy NocoDB stored as plain numbers, no units in value). nominalBoatSize: numeric('nominal_boat_size'), nominalBoatSizeM: numeric('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: numeric('power_capacity'), // kW voltage: numeric('voltage'), // V at 60Hz 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'), // Lease/rental rates surfaced by the per-berth PDFs (Phase 6b). Null // until reps upload PDFs; rendered on the berth detail page with a // "Pricing data may be stale" chip when pricing_valid_until < today(). weeklyRateHighUsd: numeric('weekly_rate_high_usd'), weeklyRateLowUsd: numeric('weekly_rate_low_usd'), dailyRateHighUsd: numeric('daily_rate_high_usd'), dailyRateLowUsd: numeric('daily_rate_low_usd'), pricingValidUntil: date('pricing_valid_until'), bowFacing: text('bow_facing'), berthApproved: boolean('berth_approved').default(false), // permanent, fixed_term, fee_simple, strata_lot (the last two map to // the Fee Simple / Strata Lot tenures shown in the per-berth PDFs). tenureType: text('tenure_type').notNull().default('permanent'), 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 }), // Optional override flag carried over from NocoDB ("auto" or null in legacy data). // Reserved for future "manual override" semantics; not surfaced in the UI today. statusOverrideMode: text('status_override_mode'), // Set by scripts/import-berths-from-nocodb.ts. The import compares this // against updated_at to detect human edits made after the last import, // so re-running the import doesn't clobber CRM-side overrides. lastImportedAt: timestamp('last_imported_at', { withTimezone: true }), // Pointer to the active per-berth PDF version (Phase 6b). Null until a // rep uploads the first PDF; a later rollback can re-target this column // to any prior `berth_pdf_versions.id`. The full history lives in the // junction table — this column is just the "current" pointer. currentPdfVersionId: text('current_pdf_version_id'), 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), ], ); // Note: `berths.current_pdf_version_id` has an `ON DELETE SET NULL` FK to // `berth_pdf_versions.id` installed by migration 0030. The column is left // without a `.references()` / `foreignKey()` declaration in the Drizzle // schema because the two tables form a circular FK (berth_pdf_versions → // berths), and Drizzle's relation inference doesn't tolerate the cycle // when both sides are declared via column-level `.references()`. The // migration chain authoritatively maintains the constraint; a fresh // `db:push` against an empty DB would skip the FK and require a follow-up // generated migration to add it back. This is acceptable because we // always apply migrations in order in dev/CI/prod. 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' }), yachtId: text('yacht_id'), // FK added via relation; nullable (waiting for this yacht) 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)], ); /** * Per-berth PDF version history (Phase 6b — see plan §3.3 / §4.7b). * * Each upload creates a new row with a monotonic `versionNumber` per berth. * The active version is referenced by `berths.current_pdf_version_id`. The * storage_key points at the file in the active `StorageBackend` (s3/filesystem), * which is resolved at access time via `getStorageBackend()`. * * `parseResults` captures what the 3-tier reverse parser extracted at upload * time plus any conflicts the rep resolved in the diff dialog. Kept as audit * trail; rolling back to a prior version does NOT replay these (per §14.6). */ export const berthPdfVersions = pgTable( 'berth_pdf_versions', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), berthId: text('berth_id') .notNull() .references(() => berths.id, { onDelete: 'cascade' }), versionNumber: integer('version_number').notNull(), /** Object key in the active storage backend (renamed from `s3_key` per §4.7a). */ storageKey: text('storage_key').notNull(), fileName: text('file_name').notNull(), fileSizeBytes: integer('file_size_bytes').notNull(), contentSha256: text('content_sha256').notNull(), uploadedBy: text('uploaded_by').notNull(), uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow(), /** Cached signed-URL expiry per §11.1 — re-sign only when within 1h of expiry. */ downloadUrlExpiresAt: timestamp('download_url_expires_at', { withTimezone: true }), /** { engine: 'acroform'|'ocr'|'ai', extracted: {...}, conflicts: [...], appliedFields: [...] } */ parseResults: jsonb('parse_results'), }, (table) => [ uniqueIndex('berth_pdf_versions_berth_version_idx').on(table.berthId, table.versionNumber), index('idx_bpv_berth').on(table.berthId, table.uploadedAt), ], ); 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; export type BerthPdfVersion = typeof berthPdfVersions.$inferSelect; export type NewBerthPdfVersion = typeof berthPdfVersions.$inferInsert;