# L2: Business Workflows — Competing Plan (Claude Code) **Duration:** Days 10–16 (7 days — one more than baseline due to scope underestimate) **Parallelism:** 4 streams, but Stream B depends on Stream A Day 2 (interests must exist before documents) **Depends on:** L1 Streams A + B complete (clients, berths, auth admin) **References:** `06-MASTER-FEATURE-SPEC.md` §2–6, `08-API-ENDPOINT-CATALOG.md` §4–9, `09-BUSINESS-RULES.md` BR-001–BR-092/BR-130–BR-152 --- ## 1. Baseline Critique The baseline `IMPL-L2-BUSINESS-WORKFLOWS.md` has solid structure but contains critical errors and missing features: ### Errors 1. **Wrong pipeline stages.** Baseline uses `new_inquiry → initial_contact → site_visit → negotiation → eoi_sent → eoi_signed → contract_sent → completed`. The actual stages from BR-010 are `open → details_sent → in_communication → visited → signed_eoi_nda → deposit_10pct → contract → completed`. This is an 8-column Kanban with completely different names — all service code, validators, UI labels, and drag-drop logic would be wrong. 2. **Wrong route paths (again).** Every UI path uses `src/app/(crm)/...` instead of the locked `src/app/(dashboard)/[portSlug]/...`. This is the third consecutive layer with this error. 3. **Wrong component paths.** Continues using `src/components/domain/interests/` etc. The locked file structure uses `src/components/interests/`, `src/components/documents/`, etc. — no `domain/` subdirectory. 4. **Wrong waiting list schema.** Baseline passes `interestId` to `addToWaitingList()` but `berth_waiting_list` table uses `client_id`, not `interest_id`. Waiting list is per-client, not per-interest. 5. **Wrong MinIO storage path.** Baseline uses `/{portId}/files/{category}/{clientId}/{filename}`. Security Guidelines mandate `{portSlug}/{entity}/{entityId}/{uuid}.{ext}`. BR-090 says `/clients/{client_id}/{category}/`. These must be reconciled — UUID-based storage paths for security, client-organized for logical structure. 6. **Outdated OpenAI model.** `gpt-4-vision-preview` is deprecated. Should use the current vision-capable model at build time (currently `gpt-4o`). ### Missing Features 7. **Document templates entirely omitted.** Master Feature Spec §4.6 defines reusable document templates with TipTap editor, merge fields (`{{client.full_name}}`), and generate/send/sign workflows. This is a significant feature with its own DB tables (`document_templates`), API endpoints (8 endpoints in the catalog), and admin UI. 8. **Pre-filled data collection forms omitted.** Master Feature Spec §4.5 — branded web forms, secure token URLs, pre-filled data, submission flow back into CRM. Tables: `form_templates`, `form_submissions`. Endpoints: `GET/POST /api/public/forms/:token`. 9. **Record PDF export omitted.** Master Feature Spec §4.7 — one-click PDF export of client/berth/interest records. Three API endpoints: `POST /api/clients/:id/export-pdf`, `/api/berths/:id/export-pdf`, `/api/interests/:id/export-pdf`. 10. **Berth status rules engine undersized.** Baseline only handles the "interest linked" trigger. BR-001 defines 7 configurable trigger rules (interest link, unlink, EOI sent, EOI signed, deposit received, contract signed, interest archived). The rules engine should be a standalone service, not embedded in berth-linking functions. 11. **Missing form expiry handling.** BR-130 requires an hourly background job checking expired form tokens. 12. **Missing fallback polling for Documenso.** Master Feature Spec §4.2 specifies a 6-hour fallback poll for pending signatures as a safety net. ### Design Issues 13. **5 days too tight.** With document templates, forms, record PDF export, and the full rules engine, 5 days is unrealistic. My plan allocates 7 days. 14. **`@dnd-kit` not in locked tech stack.** `14-TECHNICAL-DECISIONS.md` doesn't list a drag-and-drop library. Need to verify if this is an intentional omission or if the HTML Drag and Drop API with a thin wrapper suffices. I'll specify `@dnd-kit/core` + `@dnd-kit/sortable` as the pragmatic choice, but flag it as a tech-stack addition requiring approval. 15. **No error handling detail.** What happens when Documenso API is down during EOI send? When MinIO is unreachable during file upload? When OpenAI Vision returns garbage? The baseline is silent on all failure paths. --- ## 2. Implementation Plan ### Shared Patterns (Day 0 — pre-work, same session as L1 completion) These patterns extend the L1 shared infrastructure: #### Entity Service Base Extension All L2 services inherit from the base service pattern established in L1. Additional patterns for L2: ```typescript // src/lib/services/base.ts — extend with transaction helper import { db } from '@/lib/db'; import type { PgTransaction } from 'drizzle-orm/pg-core'; export type TxOrDb = PgTransaction | typeof db; /** * Run callback in a transaction, or use the provided tx if already in one. * Prevents nested transaction issues. */ export async function withTransaction( txOrDb: TxOrDb | undefined, callback: (tx: TxOrDb) => Promise, ): Promise { if (txOrDb) return callback(txOrDb); return db.transaction(callback); } ``` #### Berth Status Rules Engine This is a standalone service, not embedded in any one feature: **File:** `src/lib/services/berth-status-rules.ts` ```typescript import { z } from 'zod'; /** The 7 trigger actions from BR-001 */ export const BerthStatusTrigger = z.enum([ 'interest_linked', // First active interest linked to berth 'all_interests_unlinked', // All active interests unlinked/archived 'eoi_sent', // EOI sent on any linked interest 'eoi_signed', // EOI fully signed on any linked interest 'deposit_received', // Interest reaches deposit_10pct stage 'contract_signed', // Interest reaches contract or completed 'sole_interest_archived', // Interest manually archived (sole link) ]); export type BerthStatusTrigger = z.infer; export const BerthStatusRuleMode = z.enum(['auto', 'suggest', 'off']); export type BerthStatusRuleMode = z.infer; export const BerthStatus = z.enum(['available', 'under_offer', 'sold']); export type BerthStatus = z.infer; export interface BerthStatusRule { trigger: BerthStatusTrigger; mode: BerthStatusRuleMode; targetStatus: BerthStatus; condition?: string; // e.g., "berth_currently_available" } export interface RuleEvaluationResult { shouldChange: boolean; mode: BerthStatusRuleMode; targetStatus: BerthStatus; triggerAction: BerthStatusTrigger; berthId: string; currentStatus: BerthStatus; } /** * Load rules from system_settings for a port. Falls back to defaults. * @param portId - Port UUID * @returns Ordered array of rules */ export async function loadBerthStatusRules(portId: string): Promise; /** * Evaluate rules for a given trigger. Returns first matching rule result. * @param portId - Port UUID * @param berthId - Berth UUID * @param trigger - The trigger action that occurred * @returns Evaluation result or null if no rule matches / all rules are 'off' */ export async function evaluateRule( portId: string, berthId: string, trigger: BerthStatusTrigger, ): Promise; /** * Apply an auto rule (update berth status + audit log). * Called when rule mode is 'auto' or when user accepts a 'suggest' prompt. * @param portId - Port UUID * @param userId - User ID (for audit) * @param result - Rule evaluation result * @param tx - Optional transaction context */ export async function applyRule( portId: string, userId: string, result: RuleEvaluationResult, tx?: TxOrDb, ): Promise; ``` **Default rules constant (shipped with app):** ```typescript export const DEFAULT_BERTH_STATUS_RULES: BerthStatusRule[] = [ { trigger: 'interest_linked', mode: 'suggest', targetStatus: 'under_offer', condition: 'berth_currently_available', }, { trigger: 'all_interests_unlinked', mode: 'suggest', targetStatus: 'available', condition: 'berth_currently_under_offer', }, { trigger: 'eoi_sent', mode: 'auto', targetStatus: 'under_offer' }, { trigger: 'eoi_signed', mode: 'auto', targetStatus: 'under_offer' }, { trigger: 'deposit_received', mode: 'suggest', targetStatus: 'sold' }, { trigger: 'contract_signed', mode: 'suggest', targetStatus: 'sold' }, { trigger: 'sole_interest_archived', mode: 'suggest', targetStatus: 'available' }, ]; ``` #### Pipeline Stage Constants ```typescript // src/lib/constants/pipeline.ts /** Pipeline stages from BR-010 — order matters for Kanban columns */ export const PIPELINE_STAGES = [ 'open', 'details_sent', 'in_communication', 'visited', 'signed_eoi_nda', 'deposit_10pct', 'contract', 'completed', ] as const; export type PipelineStage = (typeof PIPELINE_STAGES)[number]; export const PIPELINE_STAGE_LABELS: Record = { open: 'Open', details_sent: 'Details Sent', in_communication: 'In Communication', visited: 'Visited', signed_eoi_nda: 'Signed EOI & NDA', deposit_10pct: '10% Deposit', contract: 'Contract', completed: 'Completed', }; export const PIPELINE_STAGE_COLORS: Record = { open: 'bg-gray-100 text-gray-700', details_sent: 'bg-blue-100 text-blue-700', in_communication: 'bg-cyan-100 text-cyan-700', visited: 'bg-teal-100 text-teal-700', signed_eoi_nda: 'bg-green-100 text-green-700', deposit_10pct: 'bg-amber-100 text-amber-700', contract: 'bg-purple-100 text-purple-700', completed: 'bg-emerald-100 text-emerald-700', }; export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const; export type LeadCategory = (typeof LEAD_CATEGORIES)[number]; export const LEAD_CATEGORY_LABELS: Record = { general_interest: 'General Interest', specific_qualified: 'Specific Qualified', hot_lead: 'Hot Lead', }; ``` --- ### Stream A: Interest Management (Days 1–4) #### Day 1: Interest CRUD + Service Layer **Drizzle schema:** `src/lib/db/schema/interests.ts` (already defined from schema migration in L0) **Validators:** `src/lib/validators/interests.ts` ```typescript import { z } from 'zod'; export const pipelineStageSchema = z.enum([ 'open', 'details_sent', 'in_communication', 'visited', 'signed_eoi_nda', 'deposit_10pct', 'contract', 'completed', ]); export const leadCategorySchema = z.enum(['general_interest', 'specific_qualified', 'hot_lead']); export const createInterestSchema = z.object({ clientId: z.string().uuid(), berthId: z.string().uuid().optional(), pipelineStage: pipelineStageSchema.default('open'), leadCategory: leadCategorySchema.optional(), source: z.enum(['website', 'manual', 'referral', 'broker']).default('manual'), reminderEnabled: z.boolean().default(false), reminderDays: z.number().int().min(1).max(365).optional(), notes: z.string().max(5000).optional(), // Milestone dates — optional, auto-populated by document workflows dateFirstContact: z.string().datetime().optional(), }); export const updateInterestSchema = z.object({ berthId: z.string().uuid().nullable().optional(), pipelineStage: pipelineStageSchema.optional(), leadCategory: leadCategorySchema.optional(), source: z.enum(['website', 'manual', 'referral', 'broker']).optional(), eoiStatus: z.enum(['waiting_for_signatures', 'signed', 'expired']).nullable().optional(), contractStatus: z.string().max(50).nullable().optional(), depositStatus: z.string().max(50).nullable().optional(), reservationStatus: z.string().max(50).nullable().optional(), reminderEnabled: z.boolean().optional(), reminderDays: z.number().int().min(1).max(365).nullable().optional(), notes: z.string().max(5000).nullable().optional(), dateFirstContact: z.string().datetime().nullable().optional(), dateLastContact: z.string().datetime().nullable().optional(), dateEoiSent: z.string().datetime().nullable().optional(), dateEoiSigned: z.string().datetime().nullable().optional(), dateContractSent: z.string().datetime().nullable().optional(), dateContractSigned: z.string().datetime().nullable().optional(), dateDepositReceived: z.string().datetime().nullable().optional(), }); export const listInterestsSchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(25), sort: z .enum(['created_at', 'updated_at', 'pipeline_stage', 'client_name', 'berth_number']) .default('updated_at'), order: z.enum(['asc', 'desc']).default('desc'), pipelineStage: pipelineStageSchema.optional(), leadCategory: leadCategorySchema.optional(), clientId: z.string().uuid().optional(), berthId: z.string().uuid().optional(), source: z.enum(['website', 'manual', 'referral', 'broker']).optional(), search: z.string().max(200).optional(), includeArchived: z.coerce.boolean().default(false), savedViewId: z.string().uuid().optional(), }); export const changeStageSchema = z.object({ newStage: pipelineStageSchema, }); ``` **Service:** `src/lib/services/interests.ts` ```typescript import type { TxOrDb } from './base'; export interface InterestListResult { interests: InterestSummary[]; meta: PaginationMeta; stageCounts: Record; } /** * List interests with pagination, filtering, and stage counts. * @param portId - Port UUID from session * @param query - Validated ListInterestsSchema * @returns Paginated interests with per-stage counts for pipeline summary * @throws {NotFoundError} If savedViewId is invalid */ export async function listInterests( portId: string, query: ListInterestsInput, ): Promise; /** * Get full interest detail including client info, berth info, notes, documents, milestones. * @param portId - Port UUID * @param interestId - Interest UUID * @returns Full interest detail * @throws {NotFoundError} If interest not found or not in port */ export async function getInterest(portId: string, interestId: string): Promise; /** * Create interest. Optionally links a berth (triggers BR-001 rules). * Auto-classifies lead category per BR-011 if vessel dimensions provided. * @param portId - Port UUID * @param userId - Creating user ID * @param data - Validated CreateInterestSchema * @returns Created interest * @throws {NotFoundError} If clientId or berthId invalid */ export async function createInterest( portId: string, userId: string, data: CreateInterestInput, tx?: TxOrDb, ): Promise; /** * Update interest fields. Detects stage changes for auto-actions. * Auto-promotes lead category per BR-011 if vessel dims become complete. * @param portId - Port UUID * @param userId - Updating user ID * @param interestId - Interest UUID * @param data - Validated UpdateInterestSchema (partial) * @returns Updated interest * @throws {NotFoundError} If interest not found */ export async function updateInterest( portId: string, userId: string, interestId: string, data: UpdateInterestInput, tx?: TxOrDb, ): Promise; /** * Change pipeline stage with business rule evaluation. * Fires berth status rules if stage crosses key thresholds (BR-012, deposit_10pct, contract). * @param portId - Port UUID * @param userId - User ID * @param interestId - Interest UUID * @param newStage - Target stage * @returns Updated interest + optional RuleEvaluationResult for UI confirmation * @throws {NotFoundError} If interest not found */ export async function changeStage( portId: string, userId: string, interestId: string, newStage: PipelineStage, ): Promise<{ interest: Interest; suggestion?: RuleEvaluationResult }>; /** * Archive (soft delete) an interest. Triggers BR-014 checks. * If sole link to berth, fires sole_interest_archived rule (BR-001). * @param portId - Port UUID * @param userId - User ID * @param interestId - Interest UUID * @throws {NotFoundError} If interest not found */ export async function archiveInterest( portId: string, userId: string, interestId: string, ): Promise<{ suggestion?: RuleEvaluationResult }>; /** * Restore an archived interest. * @param portId - Port UUID * @param userId - User ID * @param interestId - Interest UUID * @throws {NotFoundError} If interest not found or not archived */ export async function restoreInterest( portId: string, userId: string, interestId: string, ): Promise; ``` **API routes:** | Method | Path | Handler File | | ------ | -------------------------------- | ------------------------------------------------ | | GET | `/api/v1/interests` | `src/app/api/v1/interests/route.ts` | | POST | `/api/v1/interests` | `src/app/api/v1/interests/route.ts` | | GET | `/api/v1/interests/[id]` | `src/app/api/v1/interests/[id]/route.ts` | | PATCH | `/api/v1/interests/[id]` | `src/app/api/v1/interests/[id]/route.ts` | | DELETE | `/api/v1/interests/[id]` | `src/app/api/v1/interests/[id]/route.ts` | | POST | `/api/v1/interests/[id]/restore` | `src/app/api/v1/interests/[id]/restore/route.ts` | | PATCH | `/api/v1/interests/[id]/stage` | `src/app/api/v1/interests/[id]/stage/route.ts` | **Middleware chain:** `withAuth → withPermission('interests', 'view'|'create'|'edit'|'delete') → handler` **UI (Day 1 — list page only):** `src/app/(dashboard)/[portSlug]/interests/page.tsx` - Table view with columns: Client, Berth, Stage (badge), Lead Category (badge), Source, Last Contact, Created - Toggle button: Table View | Pipeline Board (board is Day 2) - Filter bar using `EntityFilters` component from L1 - Stage counts shown as pill badges above the table - "New Interest" button opens `InterestFormDialog` `src/components/interests/interest-table-columns.tsx` — column definitions `src/components/interests/interest-form-dialog.tsx` — create/edit dialog with client search (combobox) and optional berth selection **TanStack Query keys:** | Key | Endpoint | Invalidation Triggers | | --------------------------------------- | --------------------------- | --------------------------------------- | | `['interests', portId, filters]` | `GET /api/v1/interests` | Create, update, delete, stage change | | `['interests', portId, id]` | `GET /api/v1/interests/:id` | Update, stage change, berth link/unlink | | `['interests', portId, 'stage-counts']` | Derived from list | Any stage change | #### Day 2: Pipeline Board + Berth Linking **Pipeline board:** `src/components/interests/pipeline-board.tsx` ``` — main container — filter bar + view toggle — @dnd-kit/core context {PIPELINE_STAGES.map(stage => ( {interests.map(interest => ( ))} ))} ``` Props: - `PipelineColumn`: `stage: PipelineStage`, `count: number`, `children: ReactNode` - `PipelineCard`: `interest: InterestSummary` — shows client name, vessel name, berth (if linked), lead category badge, days in stage, next action indicator **On drag-end handler:** 1. Optimistic update via TanStack Query `setQueryData` 2. `PATCH /api/v1/interests/:id/stage` with `newStage` 3. If response includes `suggestion` (rule mode = "suggest"): show confirmation dialog 4. If user accepts suggestion: `PATCH /api/v1/berths/:berthId` with new status 5. On error: revert optimistic update, show toast with error message **Berth linking service:** `src/lib/services/interest-berth.ts` ```typescript /** * Link a berth to an interest. Evaluates BR-001 rules. * @returns The updated interest + optional rule suggestion for UI * @throws {ConflictError} If berth is already linked to this interest */ export async function linkBerth( portId: string, userId: string, interestId: string, berthId: string, tx?: TxOrDb, ): Promise<{ interest: Interest; suggestion?: RuleEvaluationResult }>; /** * Unlink berth from interest. Checks if other active interests remain on berth. * If no other interests: evaluates all_interests_unlinked rule. * @returns The updated interest + optional rule suggestion */ export async function unlinkBerth( portId: string, userId: string, interestId: string, tx?: TxOrDb, ): Promise<{ interest: Interest; suggestion?: RuleEvaluationResult }>; /** * Accept a berth status suggestion from the rules engine. * Called when user clicks "Yes, change status" on the suggestion dialog. */ export async function acceptBerthStatusSuggestion( portId: string, userId: string, result: RuleEvaluationResult, tx?: TxOrDb, ): Promise; ``` **API routes:** | Method | Path | File | | ------ | ------------------------------ | ---------------------------------------------- | | POST | `/api/v1/interests/[id]/berth` | `src/app/api/v1/interests/[id]/berth/route.ts` | | DELETE | `/api/v1/interests/[id]/berth` | `src/app/api/v1/interests/[id]/berth/route.ts` | **Berth status suggestion dialog:** `src/components/interests/berth-status-suggestion-dialog.tsx` - Renders when API returns a `suggestion` with mode `suggest` - Shows: "Change berth {mooring_number} status from {current} to {target}?" - Two buttons: "Yes, change" → `POST /api/v1/berths/:id/status` | "No, keep current" → dismiss **Socket.io events emitted:** - `interest:created` → `{ interestId, clientId, berthId?, portId }` - `interest:updated` → `{ interestId, fields: string[], portId }` - `interest:stageChanged` → `{ interestId, oldStage, newStage, portId }` - `interest:berthLinked` → `{ interestId, berthId, portId }` - `interest:berthUnlinked` → `{ interestId, berthId, portId }` - `berth:statusChanged` → `{ berthId, oldStatus, newStatus, trigger, portId }` (when rule fires) **Query invalidation on socket events:** ```typescript // src/lib/hooks/use-interest-socket.ts useSocketEvent('interest:stageChanged', ({ interestId }) => { queryClient.invalidateQueries({ queryKey: ['interests', portId] }); queryClient.invalidateQueries({ queryKey: ['interests', portId, interestId] }); }); useSocketEvent('berth:statusChanged', ({ berthId }) => { queryClient.invalidateQueries({ queryKey: ['berths', portId] }); queryClient.invalidateQueries({ queryKey: ['berths', portId, berthId] }); }); ``` #### Day 3: Notes, Tags, Milestones, Recommendations **Interest notes** — reuse the notes pattern from L1 clients: `src/lib/services/interest-notes.ts` — same signature pattern as client notes `src/lib/validators/interest-notes.ts` — same as client notes `src/app/api/v1/interests/[id]/notes/route.ts` — GET, POST `src/app/api/v1/interests/[id]/notes/[noteId]/route.ts` — PATCH (15-min window) **Interest tags** — reuse L1 tag pattern: `src/app/api/v1/interests/[id]/tags/route.ts` — POST, DELETE **Milestone timeline:** `src/components/interests/milestone-timeline.tsx` - Horizontal timeline showing the 7 milestone dates as nodes - Each node: label, date (if set), status indicator (completed green check / pending gray circle) - Milestones in order: First Contact → EOI Sent → EOI Signed → Contract Sent → Contract Signed → Deposit Received → Completed - Clicking a pending milestone offers "Set date" action - Completed milestones show the date with a tooltip of who/how it was set (manual vs auto) **Interest detail page:** `src/app/(dashboard)/[portSlug]/interests/[id]/page.tsx` Layout: ``` — client name, berth badge, stage badge, actions dropdown Overview Notes Documents Timeline — editable fields, milestones, berth info, recommendations — inline editable fields — linked berth summary or "Link berth" button — berth recommendations {/* Stream B builds this */} ``` **Berth recommendation engine:** `src/lib/services/berth-recommendations.ts` ```typescript export interface RecommendationInput { vesselLengthM: number; vesselWidthM: number; vesselDraftM: number; preferredArea?: string; powerRequired?: string; } export interface RecommendationResult { berthId: string; mooringNumber: string; area: string; matchScore: number; // 0-100 matchReasons: { dimensionalFit: number; // 0-100: how well vessel fits powerMatch: number; // 0-100: power capacity match accessRating: number; // 0-100: access quality priceEfficiency: number; // 0-100: price relative to size availability: number; // 0-100: 100=available, 50=under_offer, 0=sold }; clearance: { lengthBufferM: number; widthBufferM: number; depthBufferM: number; }; } /** * Generate berth recommendations for an interest based on vessel dimensions. * Algorithm (from 06-MASTER-FEATURE-SPEC §3.3): * 1. Filter: berth_length >= vessel_length + 2m, berth_width >= vessel_width + 1m, * berth_depth >= vessel_draft + 0.5m * 2. Score: dimensional fit (40%), power match (15%), access (15%), price (15%), availability (15%) * 3. Sort by total score descending, return top 10 * * @param portId - Port UUID * @param input - Vessel dimensions + preferences * @returns Top 10 ranked berths with match details */ export async function generateRecommendations( portId: string, input: RecommendationInput, ): Promise; /** * Save generated recommendations to berth_recommendations table. * @param interestId - Interest UUID * @param results - Recommendation results to persist * @param userId - User who generated them (or 'system' for auto) */ export async function saveRecommendations( portId: string, interestId: string, results: RecommendationResult[], userId: string, ): Promise; /** * Add a manual berth recommendation. */ export async function addManualRecommendation( portId: string, interestId: string, berthId: string, userId: string, ): Promise; ``` API routes: | Method | Path | File | |--------|------|------| | GET | `/api/v1/interests/[id]/recommendations` | `src/app/api/v1/interests/[id]/recommendations/route.ts` | | POST | `/api/v1/interests/[id]/recommendations/generate` | `src/app/api/v1/interests/[id]/recommendations/generate/route.ts` | | POST | `/api/v1/interests/[id]/recommendations` | `src/app/api/v1/interests/[id]/recommendations/route.ts` | UI: `src/components/interests/recommendation-panel.tsx` - If interest has vessel dimensions: "Generate Recommendations" button - Results as a ranked card list: mooring number, area, match score (progress bar), clearance values - Each card has "Link this berth" button → calls `linkBerth()` → triggers BR-001 #### Day 4: Waiting List, Lead Categories, Public API **Waiting list service:** `src/lib/services/berth-waiting-list.ts` ```typescript /** * Add a client to a berth's waiting list. * @param portId - Port UUID * @param userId - Acting user ID * @param berthId - Berth UUID * @param clientId - Client UUID (NOT interest ID — schema uses client_id) * @param priority - 'normal' | 'high' * @param notifyPref - 'email' | 'in_app' | 'both' * @throws {ConflictError} If client already on this berth's waiting list */ export async function addToWaitingList( portId: string, userId: string, berthId: string, clientId: string, priority?: 'normal' | 'high', notifyPref?: 'email' | 'in_app' | 'both', ): Promise; /** * Remove a client from a berth's waiting list. */ export async function removeFromWaitingList( portId: string, userId: string, entryId: string, ): Promise; /** * Reorder waiting list entries for a berth. * @param orderedEntryIds - Entry IDs in desired order. Position auto-assigned 1..N. */ export async function reorderWaitingList( portId: string, userId: string, berthId: string, orderedEntryIds: string[], ): Promise; /** * Notify waiting list clients when a berth becomes available. * Called by berth status rules engine when status changes to 'available'. * Notifications staggered per BR-134: high priority first, then by position, * 1 hour delay between each notification. */ export async function notifyWaitingList(portId: string, berthId: string): Promise; ``` API routes — already defined in L1 berth routes: | Method | Path | |--------|------| | GET | `/api/v1/berths/[id]/waiting-list` | | POST | `/api/v1/berths/[id]/waiting-list` | | PATCH | `/api/v1/berths/[id]/waiting-list/[entryId]` | | DELETE | `/api/v1/berths/[id]/waiting-list/[entryId]` | **Lead category auto-classification:** Handled within `updateInterest()` and `createInterest()`: - BR-011: If all three yacht dimensions (length, width, draft) are populated and current category is `general_interest` → auto-promote to `specific_qualified` - Additional rule: If berth is linked → at least `specific_qualified` - Additional rule: If EOI sent → `hot_lead` - All auto-classifications logged in audit, manually overridable **Public interest registration API:** `src/app/api/public/interests/route.ts` ```typescript import { z } from 'zod'; const publicInterestSchema = z.object({ name: z.string().min(2).max(200), email: z.string().email(), phone: z.string().max(50).optional(), vesselName: z.string().max(200).optional(), vesselLength: z.number().positive().optional(), vesselWidth: z.number().positive().optional(), vesselDraft: z.number().positive().optional(), berthPreference: z.string().max(200).optional(), message: z.string().max(2000).optional(), portSlug: z.string(), // which port this registration is for }); export async function POST(request: NextRequest) { // 1. Rate limit: 10 requests/minute per IP // 2. Validate body with publicInterestSchema // 3. Resolve port from portSlug // 4. BR-030: Check if email matches existing client_contacts // - Match found → create interest under existing client // - No match → create new client + interest // 5. BR-031: Run fuzzy duplicate detection on new client // - Score >= 0.7 → create duplicate alert // 6. Set pipeline_stage = 'open' // 7. Emit notification to all sales users at port (BullMQ job) // 8. Return { success: true, referenceNumber: 'INT-{shortId}' } // Error: Never reveal whether email exists — always return success shape } ``` CORS: configured to allow only `PUBLIC_SITE_URL` origin. No auth required. Rate limited at nginx (5/min) + application (10/min per IP). --- ### Stream B: EOI & Document Signing (Days 2–5) #### Day 2: Document CRUD + Service **Service:** `src/lib/services/documents.ts` ```typescript /** * List documents with filtering. * @param portId - Port UUID * @param query - Filters: type, status, clientId, interestId * @returns Paginated document list */ export async function listDocuments( portId: string, query: ListDocumentsInput, ): Promise; /** * Get document detail with signers, events, and file URLs. * Generates presigned URLs for original and signed files. * @throws {NotFoundError} If document not in port */ export async function getDocument(portId: string, documentId: string): Promise; /** * Create a document record (manual upload or pre-creation for Documenso). * @param portId - Port UUID * @param userId - Creating user ID * @param data - Document data with optional file */ export async function createDocument( portId: string, userId: string, data: CreateDocumentInput, tx?: TxOrDb, ): Promise; /** * Upload a manually signed document. Sets status to 'completed'. * Auto-populates interest milestones per BR-133. * @param portId - Port UUID * @param userId - Uploading user ID * @param documentId - Existing document ID (or creates new if not provided) * @param file - The signed document file * @param interestId - Optional interest to link and auto-update milestones */ export async function uploadSignedDocument( portId: string, userId: string, documentId: string, file: File, tx?: TxOrDb, ): Promise; ``` **Validators:** `src/lib/validators/documents.ts` ```typescript export const createDocumentSchema = z.object({ interestId: z.string().uuid().optional(), clientId: z.string().uuid().optional(), documentType: z.enum(['eoi', 'contract', 'nda', 'reservation_agreement', 'other']), title: z.string().min(1).max(500), notes: z.string().max(5000).optional(), }); export const listDocumentsSchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(25), documentType: z.enum(['eoi', 'contract', 'nda', 'reservation_agreement', 'other']).optional(), status: z .enum(['draft', 'sent', 'partially_signed', 'completed', 'expired', 'cancelled']) .optional(), clientId: z.string().uuid().optional(), interestId: z.string().uuid().optional(), }); ``` **API routes:** | Method | Path | File | | ------ | -------------------------------------- | ------------------------------------------------------ | | GET | `/api/v1/documents` | `src/app/api/v1/documents/route.ts` | | POST | `/api/v1/documents` | `src/app/api/v1/documents/route.ts` | | GET | `/api/v1/documents/[id]` | `src/app/api/v1/documents/[id]/route.ts` | | PATCH | `/api/v1/documents/[id]` | `src/app/api/v1/documents/[id]/route.ts` | | DELETE | `/api/v1/documents/[id]` | `src/app/api/v1/documents/[id]/route.ts` | | POST | `/api/v1/documents/[id]/send` | `src/app/api/v1/documents/[id]/send/route.ts` | | POST | `/api/v1/documents/[id]/upload-signed` | `src/app/api/v1/documents/[id]/upload-signed/route.ts` | | POST | `/api/v1/documents/[id]/remind` | `src/app/api/v1/documents/[id]/remind/route.ts` | | GET | `/api/v1/documents/[id]/signers` | `src/app/api/v1/documents/[id]/signers/route.ts` | | GET | `/api/v1/documents/[id]/events` | `src/app/api/v1/documents/[id]/events/route.ts` | | POST | `/api/v1/documents/generate-eoi` | `src/app/api/v1/documents/generate-eoi/route.ts` | #### Day 3: EOI Generation + Documenso Integration **EOI service:** `src/lib/services/eoi.ts` ```typescript /** * Generate an EOI document for an interest. * Validates prerequisites (BR-020), generates PDF, creates Documenso document, * assigns signers, and updates interest milestones. * * @param portId - Port UUID * @param userId - Generating user ID * @param interestId - Interest UUID * @returns Created document with signing URLs * @throws {ValidationError} If prerequisites not met (BR-020): * - Client missing name or email * - Interest missing yacht name, length, width, draft * - No berth linked * - Existing EOI exists (unless override=true) * @throws {ExternalServiceError} If Documenso API or @pdfme fails */ export async function generateEOI( portId: string, userId: string, interestId: string, options?: { override?: boolean }, ): Promise; ``` **Implementation steps within `generateEOI()`:** 1. Load interest with client and berth data (join query) 2. Validate BR-020 prerequisites — return field-level errors if any missing 3. Load port settings for branding (logo, colors) 4. Generate PDF via `@pdfme/generator`: - Template: `src/lib/pdf/templates/eoi-template.ts` - Branded header with port logo, navy (#1e2844) accent, Georgia font for body - Content: client name, vessel specs, berth details, pricing, terms, signature blocks 5. Upload generated PDF to MinIO: `{portSlug}/documents/eoi/{interestId}/{uuid}.pdf` 6. Create `files` DB record for the PDF 7. Create Documenso document via API: ```typescript const documensoDoc = await documensoClient.createDocument({ title: `EOI - ${client.fullName} - Berth ${berth.mooringNumber}`, file: pdfBuffer, }); // Add 3 signers in sequential order (BR-021) await documensoClient.addRecipient(documensoDoc.id, { name: client.fullName, email: clientPrimaryEmail, role: 'SIGNER', signingOrder: 1, }); await documensoClient.addRecipient(documensoDoc.id, { name: portSettings.developerSignerName, email: portSettings.developerSignerEmail, role: 'SIGNER', signingOrder: 2, }); await documensoClient.addRecipient(documensoDoc.id, { name: portSettings.approverSignerName, email: portSettings.approverSignerEmail, role: 'SIGNER', signingOrder: 3, }); // Send the document await documensoClient.sendDocument(documensoDoc.id); ``` 8. Create `documents` DB record with `documenso_id`, `file_id`, status `sent` 9. Create `document_signers` records with signing URLs from Documenso response 10. Create `document_events` record: type `sent` 11. Update interest: `date_eoi_sent = now()`, `eoi_status = 'waiting_for_signatures'` 12. Evaluate BR-012: advance `pipeline_stage` to `signed_eoi_nda` if not already past 13. Evaluate berth status rule: `eoi_sent` trigger 14. Audit log, socket events **Documenso client wrapper:** `src/lib/services/documenso-client.ts` ```typescript /** * Wrapper around Documenso REST API with retry logic. * All methods throw ExternalServiceError on failure. */ export class DocumensoClient { constructor(private apiUrl: string, private apiKey: string); async createDocument(data: { title: string; file: Buffer }): Promise; async addRecipient(documentId: string, data: RecipientInput): Promise; async sendDocument(documentId: string): Promise; async getDocument(documentId: string): Promise; async getRecipients(documentId: string): Promise; async downloadSignedPdf(documentId: string): Promise; async sendReminder(documentId: string, recipientId: string): Promise; } ``` **@pdfme template:** `src/lib/pdf/templates/eoi-template.ts` - Schema definition with placeholder rectangles for each field - Uses `@pdfme/schemas` for text, image, table, line primitives - Port logo as image field (loaded from MinIO or port settings) - Footer with page numbers and "Generated by Port Nimara CRM" `src/lib/pdf/generate.ts` — shared PDF generation wrapper: ```typescript import { generate } from '@pdfme/generator'; import type { Template } from '@pdfme/common'; /** * Generate a PDF from a template and input data. * @param template - @pdfme template definition * @param inputs - Data to populate the template * @returns PDF as Buffer */ export async function generatePdf( template: Template, inputs: Record[], ): Promise; ``` **Error handling for EOI generation:** - Documenso API down → catch, log error, return 503 with "Document signing service temporarily unavailable. Please try again." - @pdfme fails → catch, log, return 500 with generic error. Log the template + input data (minus PII) for debugging. - MinIO upload fails → catch, log, return 503 with "File storage temporarily unavailable." - All partial work is rolled back via transaction (PDF upload cleaned up if Documenso fails) #### Day 4: Documenso Webhook + Signing UI **Webhook handler:** `src/app/api/webhooks/documenso/route.ts` ```typescript export async function POST(request: NextRequest) { // 1. Verify webhook signature const signature = request.headers.get('x-documenso-signature'); const body = await request.text(); if (!verifyDocumensoSignature(body, signature, env.DOCUMENSO_WEBHOOK_SECRET)) { return new Response('Invalid signature', { status: 401 }); } const event = JSON.parse(body); // 2. Deduplication via signature_hash on document_events const signatureHash = createHash('sha256').update(body).digest('hex'); const existing = await db .select() .from(documentEvents) .where(eq(documentEvents.signatureHash, signatureHash)) .limit(1); if (existing.length > 0) { return new Response('Already processed', { status: 200 }); } // 3. Process event type switch (event.event) { case 'RECIPIENT_SIGNED': await handleRecipientSigned(event); break; case 'DOCUMENT_COMPLETED': await handleDocumentCompleted(event); break; case 'DOCUMENT_EXPIRED': await handleDocumentExpired(event); break; } return new Response('OK', { status: 200 }); } async function handleRecipientSigned(event: DocumensoEvent) { // 1. Find document by documenso_id // 2. Update document_signers: set signed_at, status = 'signed' // 3. Create document_events entry with signature_hash // 4. Check if all signers complete → if yes, call handleDocumentCompleted // 5. Emit socket: document:signed // 6. Create notification for next signer and interest owner (BR-050) } async function handleDocumentCompleted(event: DocumensoEvent) { // 1. Download completed PDF from Documenso API // 2. Upload to MinIO: {portSlug}/documents/{docType}/{interestId}/signed-{uuid}.pdf // 3. Create files record for signed PDF // 4. Update document: status = 'completed', signed_file_id = newFileId // 5. Auto-populate milestones per BR-133: // - EOI type → set interest.date_eoi_signed // - Contract type → set interest.date_contract_signed // 6. Update interest.eoi_status = 'signed' (for EOI type) // 7. Email signed PDF to all signers (BullMQ email job) // 8. Emit socket: document:completed // 9. Evaluate berth status rules: eoi_signed trigger // 10. Audit log } async function handleDocumentExpired(event: DocumensoEvent) { // 1. Update document status to 'expired' // 2. Update interest.eoi_status = 'expired' (for EOI type) // 3. Create notification for assigned sales user // 4. Emit socket: document:expired } ``` **Webhook signature verification:** ```typescript // src/lib/services/documenso-webhook.ts import { createHmac } from 'crypto'; export function verifyDocumensoSignature( payload: string, signature: string | null, secret: string, ): boolean { if (!signature) return false; const expected = createHmac('sha256', secret).update(payload).digest('hex'); return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); } ``` **Signing progress UI:** `src/components/documents/signing-progress.tsx` ``` {document.signers.map(signer => ( resendReminder(signer.id)} onView={() => window.open(signer.signingUrl, '_blank')} /> ))} ``` **Document list on interest detail:** `src/components/documents/document-list.tsx` - Shows all documents linked to an interest - Columns: type badge, title, status badge, signers progress (e.g., "2/3 signed"), created date - Actions per document: View, Remind, Upload Signed Copy - "Generate EOI" button at top (disabled if prerequisites not met — show tooltip explaining what's missing) #### Day 5: Signing Reminders + Fallback Polling + Manual Upload **Signing reminders service:** `src/lib/services/document-reminders.ts` ```typescript /** * Send a signing reminder for a specific document to pending signers. * Respects: cooldown window (BR-023), send window (9:00-16:00 port TZ), * and per-interest reminder_enabled toggle. * * @returns Number of reminders sent * @throws {ValidationError} If reminder not allowed (cooldown, outside window, disabled) */ export async function sendReminder( portId: string, userId: string, documentId: string, ): Promise<{ sent: number; skipped: string[] }>; /** * Check all pending documents for due reminders and send them. * Called by BullMQ recurring job every 10 minutes. * Schedule: remind at 24h, 72h, 7 days after send. */ export async function processReminderQueue(portId: string): Promise; ``` **BullMQ job definition:** ```typescript // src/jobs/processors/document-reminder.ts { name: 'document-reminders', queue: 'documents', concurrency: 1, repeat: { every: 600_000 }, // 10 minutes processor: async (job) => { const ports = await getActivePorts(); for (const port of ports) { await processReminderQueue(port.id); } }, } ``` **Fallback polling (Master Feature Spec §4.2):** ```typescript // src/jobs/processors/documenso-poll.ts { name: 'documenso-fallback-poll', queue: 'documents', concurrency: 1, repeat: { every: 21_600_000 }, // 6 hours processor: async (job) => { // 1. Query documents with status 'sent' or 'partially_signed' // 2. For each, check Documenso API for current status // 3. If status changed, process as if webhook was received // 4. Log any discrepancies (webhook missed events) }, } ``` **Manual document upload flow:** 1. User clicks "Upload Signed Copy" on interest or document page 2. File picker: accepts PDF, max 50MB 3. Upload to MinIO via file service 4. If creating new document: create `documents` record with `is_manual_upload = true`, status `completed` 5. If updating existing document: set `signed_file_id`, status `completed` 6. Auto-populate milestones (BR-133): EOI type → `date_eoi_signed`, contract → `date_contract_signed` 7. Advance pipeline stage if appropriate (BR-013) 8. Audit log, socket event --- ### Stream C: Expenses & Invoices (Days 3–6) #### Day 3: Expense CRUD **Validators:** `src/lib/validators/expenses.ts` ```typescript export const createExpenseSchema = z.object({ establishmentName: z.string().max(500).optional(), amount: z.number().positive(), currency: z.string().length(3).default('USD'), // ISO 4217 paymentMethod: z.string().max(50).optional(), category: z .enum([ 'fuel', 'maintenance', 'supplies', 'utilities', 'personnel', 'office', 'travel', 'entertainment', 'insurance', 'professional_services', 'equipment', 'other', ]) .optional(), payer: z.string().max(200).optional(), expenseDate: z.string().datetime(), description: z.string().max(5000).optional(), paymentStatus: z.enum(['unpaid', 'paid', 'partial']).default('unpaid'), paymentDate: z.string().date().optional(), paymentReference: z.string().max(200).optional(), paymentNotes: z.string().max(2000).optional(), }); export const updateExpenseSchema = createExpenseSchema.partial(); export const listExpensesSchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(25), sort: z .enum(['expense_date', 'amount', 'created_at', 'establishment_name']) .default('expense_date'), order: z.enum(['asc', 'desc']).default('desc'), category: z.string().optional(), payer: z.string().optional(), paymentStatus: z.enum(['unpaid', 'paid', 'partial']).optional(), currency: z.string().length(3).optional(), dateFrom: z.string().date().optional(), dateTo: z.string().date().optional(), search: z.string().max(200).optional(), }); ``` **Service:** `src/lib/services/expenses.ts` ```typescript /** * Create expense with automatic currency conversion (BR-040). * If currency != USD, converts to USD using cached exchange rate. * If rate unavailable, saves without conversion and flags for manual entry. * * @returns Created expense with amount_usd and exchange_rate populated (or null if no rate) */ export async function createExpense( portId: string, userId: string, data: CreateExpenseInput, receiptFileIds?: string[], ): Promise; /** * List expenses with pagination and filtering. */ export async function listExpenses( portId: string, query: ListExpensesInput, ): Promise; /** * Get single expense with receipt file presigned URLs. */ export async function getExpense(portId: string, expenseId: string): Promise; /** * Update expense. Recalculates amount_usd if amount or currency changed. */ export async function updateExpense( portId: string, userId: string, expenseId: string, data: UpdateExpenseInput, ): Promise; /** * Archive (soft delete) expense. * @throws {ConflictError} If expense is linked to a non-draft invoice (BR-045) */ export async function archiveExpense( portId: string, userId: string, expenseId: string, ): Promise; ``` **API routes:** | Method | Path | File | | ------ | ----------------------- | --------------------------------------- | | GET | `/api/v1/expenses` | `src/app/api/v1/expenses/route.ts` | | POST | `/api/v1/expenses` | `src/app/api/v1/expenses/route.ts` | | GET | `/api/v1/expenses/[id]` | `src/app/api/v1/expenses/[id]/route.ts` | | PATCH | `/api/v1/expenses/[id]` | `src/app/api/v1/expenses/[id]/route.ts` | | DELETE | `/api/v1/expenses/[id]` | `src/app/api/v1/expenses/[id]/route.ts` | **Currency conversion service:** `src/lib/services/currency.ts` ```typescript /** * Get exchange rate from currency_rates cache. * @param from - Source currency (ISO 4217) * @param to - Target currency (ISO 4217) * @returns Exchange rate or null if unavailable */ export async function getRate(from: string, to: string): Promise; /** * Convert amount using cached rate. * @returns Converted amount and rate used, or null if rate unavailable */ export async function convert( amount: number, from: string, to: string, ): Promise<{ converted: number; rate: number } | null>; /** * Refresh exchange rates from Frankfurter API. * Caches for 24 hours. Fallback to last cached rate if API down (BR-045 Frankfurter). * Called by BullMQ recurring job every 6 hours. */ export async function refreshRates(): Promise; ``` **BullMQ job:** ```typescript { name: 'currency-rate-refresh', queue: 'maintenance', concurrency: 1, repeat: { every: 21_600_000 }, // 6 hours processor: async () => { await refreshRates(); }, } ``` **UI:** `src/app/(dashboard)/[portSlug]/expenses/page.tsx` - Table with columns: Date, Establishment, Amount (original + USD), Category, Payer, Payment Status, Actions - Filter bar: date range picker, category dropdown, payer search, payment status, currency - "New Expense" button opens `ExpenseFormDialog` - Batch selection for invoice creation and export `src/components/expenses/expense-form-dialog.tsx` - Receipt upload zone with image preview - Currency selector with auto-conversion preview ("$150 ECD ≈ $55.56 USD") - Category dropdown `src/app/(dashboard)/[portSlug]/expenses/[id]/page.tsx` - Detail view with receipt image display (inline preview via presigned URL) - Edit inline - Payment recording section #### Day 4: Receipt Scanner + Multi-Currency **Receipt scanner route:** `src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx` NOT a PWA — integrated into the main app as a mobile-friendly page. The baseline's PWA approach adds significant complexity (service worker, IndexedDB, offline sync) for a feature that requires internet connectivity anyway (OpenAI API). Instead, make the scan page responsive and installable via PWA manifest if needed later. Layout: ``` — "Scan Receipt" title, back button — uses navigator.mediaDevices.getUserMedia or — drag-and-drop or file picker for gallery — shows captured/uploaded image — sends to API — after API returns ``` **Receipt scanner API:** `src/app/api/v1/expenses/scan-receipt/route.ts` ```typescript export async function POST(request: NextRequest) { // 1. Auth + permission check (expenses.create) // 2. Extract file from multipart form data // 3. Validate: image MIME type, max 10MB // 4. Call OpenAI Vision API (NO PII sent — only receipt image per Security Guidelines §7.4) // 5. Parse response, validate extracted data // 6. Return extracted fields for user review } ``` **Receipt scanner service:** `src/lib/services/receipt-scanner.ts` ```typescript import OpenAI from 'openai'; export interface ScanResult { establishment: string | null; date: string | null; // ISO date amount: number | null; currency: string | null; // ISO 4217 lineItems: Array<{ name: string; amount: number }>; confidence: number; // 0-1 overall confidence } /** * Scan a receipt image using OpenAI Vision API. * @param imageBuffer - Receipt image as Buffer * @param mimeType - Image MIME type * @returns Extracted receipt data for user review * @throws {ExternalServiceError} If OpenAI API fails */ export async function scanReceipt(imageBuffer: Buffer, mimeType: string): Promise { const openai = new OpenAI({ apiKey: env.OPENAI_API_KEY }); const response = await openai.chat.completions.create({ model: 'gpt-4o', // Current vision-capable model, NOT deprecated gpt-4-vision-preview messages: [ { role: 'user', content: [ { type: 'text', text: `Extract from this receipt: establishment name, date (ISO 8601), total amount (number only), currency (ISO 4217 code), and line items (each with name and amount). Return JSON with keys: establishment, date, amount, currency, lineItems (array of {name, amount}), confidence (0-1).`, }, { type: 'image_url', image_url: { url: `data:${mimeType};base64,${imageBuffer.toString('base64')}`, }, }, ], }, ], response_format: { type: 'json_object' }, max_tokens: 1000, }); const parsed = JSON.parse(response.choices[0].message.content ?? '{}'); return scanResultSchema.parse(parsed); // Validate with Zod — don't trust extracted values } ``` **Error handling for receipt scanner:** - OpenAI API unavailable: return 503 "Receipt scanning is temporarily unavailable. You can enter the expense manually." - OpenAI returns garbage/low confidence: still return results but set `confidence` field low. UI shows warning: "Low confidence — please verify all fields." - Image too small/blurry: OpenAI may return empty fields. UI handles gracefully by showing empty form. #### Day 5: Invoicing **Service:** `src/lib/services/invoices.ts` ```typescript /** * Create invoice from selected expenses or manual line items. * Auto-generates invoice number per BR-041: INV-YYYYMM-### sequential per port per month. * Applies Net 10 discount if applicable (BR-042). * Links expenses via invoice_expenses junction table in a single transaction (BR-045). * * @param portId - Port UUID * @param userId - Creating user * @param data - Invoice data with line items or expense IDs * @returns Created invoice with line items * @throws {ConflictError} If any selected expense is already linked to a non-draft invoice */ export async function createInvoice( portId: string, userId: string, data: CreateInvoiceInput, ): Promise; /** * Generate next invoice number for port+month. * Uses advisory lock to prevent race conditions on sequential numbering. */ export async function generateInvoiceNumber(portId: string, tx: TxOrDb): Promise; /** * Generate invoice PDF via @pdfme. * @returns PDF buffer and file ID after MinIO upload */ export async function generateInvoicePdf( portId: string, invoiceId: string, ): Promise<{ buffer: Buffer; fileId: string }>; /** * Send invoice via email to billing_email. * Generates PDF if not already generated, then queues email via BullMQ. */ export async function sendInvoice(portId: string, userId: string, invoiceId: string): Promise; /** * Record payment against an invoice. * Updates invoice status and payment fields. */ export async function recordPayment( portId: string, userId: string, invoiceId: string, data: RecordPaymentInput, ): Promise; /** * List invoices with filtering. */ export async function listInvoices( portId: string, query: ListInvoicesInput, ): Promise; /** * Get invoice detail with line items and linked expenses. */ export async function getInvoice(portId: string, invoiceId: string): Promise; ``` **Validators:** `src/lib/validators/invoices.ts` ```typescript export const createInvoiceSchema = z .object({ clientName: z.string().min(1).max(500), billingEmail: z.string().email().optional(), billingAddress: z.string().max(1000).optional(), dueDate: z.string().date(), paymentTerms: z .enum(['immediate', 'net10', 'net15', 'net30', 'net45', 'net60']) .default('net30'), currency: z.string().length(3).default('USD'), notes: z.string().max(5000).optional(), // Either provide expense IDs or manual line items (or both) expenseIds: z.array(z.string().uuid()).optional(), lineItems: z .array( z.object({ description: z.string().min(1).max(500), quantity: z.number().positive().default(1), unitPrice: z.number(), }), ) .optional(), }) .refine((data) => (data.expenseIds?.length ?? 0) > 0 || (data.lineItems?.length ?? 0) > 0, { message: 'Must provide either expense IDs or line items', }); export const recordPaymentSchema = z.object({ paymentDate: z.string().date(), paymentMethod: z.string().max(50), paymentReference: z.string().max(200).optional(), amount: z.number().positive().optional(), // For partial payments }); ``` **Invoice number generation (BR-041):** ```typescript async function generateInvoiceNumber(portId: string, tx: TxOrDb): Promise { const now = new Date(); const yearMonth = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`; const prefix = `INV-${yearMonth}-`; // Use pg_advisory_xact_lock to prevent race conditions await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${prefix} || ${portId}))`); const lastInvoice = await tx .select({ invoiceNumber: invoices.invoiceNumber }) .from(invoices) .where(and(eq(invoices.portId, portId), like(invoices.invoiceNumber, `${prefix}%`))) .orderBy(desc(invoices.invoiceNumber)) .limit(1); const nextSeq = lastInvoice.length > 0 ? parseInt(lastInvoice[0].invoiceNumber.slice(-3)) + 1 : 1; return `${prefix}${String(nextSeq).padStart(3, '0')}`; } ``` **Net 10 discount (BR-042):** ```typescript function calculateInvoiceTotals( lineItems: LineItem[], paymentTerms: string, feePct: number = 0, ): { subtotal: number; discountPct: number; discountAmount: number; feePct: number; feeAmount: number; total: number; } { const subtotal = lineItems.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0); // BR-042: 2% discount for Net 10 (configurable via system_settings) const discountPct = paymentTerms === 'net10' ? 2 : 0; const discountAmount = subtotal * (discountPct / 100); const feeAmount = subtotal * (feePct / 100); const total = subtotal - discountAmount + feeAmount; return { subtotal, discountPct, discountAmount, feePct, feeAmount, total: Math.round(total * 100) / 100, }; } ``` **Invoice PDF template:** `src/lib/pdf/templates/invoice-template.ts` - Port branding header (logo, name, address) - Invoice number, date, due date, payment terms - Client billing info - Line items table: Description | Qty | Unit Price | Total - Subtotal, discount (if applicable), fees (if applicable), total - Payment instructions section - Footer with port contact info **API routes:** | Method | Path | File | | ------ | ------------------------------------ | ---------------------------------------------------- | | GET | `/api/v1/invoices` | `src/app/api/v1/invoices/route.ts` | | POST | `/api/v1/invoices` | `src/app/api/v1/invoices/route.ts` | | GET | `/api/v1/invoices/[id]` | `src/app/api/v1/invoices/[id]/route.ts` | | PATCH | `/api/v1/invoices/[id]` | `src/app/api/v1/invoices/[id]/route.ts` | | DELETE | `/api/v1/invoices/[id]` | `src/app/api/v1/invoices/[id]/route.ts` | | POST | `/api/v1/invoices/[id]/send` | `src/app/api/v1/invoices/[id]/send/route.ts` | | POST | `/api/v1/invoices/[id]/generate-pdf` | `src/app/api/v1/invoices/[id]/generate-pdf/route.ts` | | PATCH | `/api/v1/invoices/[id]/payment` | `src/app/api/v1/invoices/[id]/payment/route.ts` | **UI:** `src/app/(dashboard)/[portSlug]/invoices/page.tsx` — list with status filter tabs (Draft, Sent, Paid, Overdue) `src/app/(dashboard)/[portSlug]/invoices/[id]/page.tsx` — detail with line items, payment recording `src/app/(dashboard)/[portSlug]/invoices/new/page.tsx` — create wizard: 1. Select client (searchable combobox) 2. Select unpaid expenses or enter manual line items 3. Review: line items, totals, discount preview 4. Set payment terms, due date 5. Generate → creates invoice `src/components/invoices/invoice-line-items.tsx` — editable line item table `src/components/invoices/invoice-pdf-preview.tsx` — embedded PDF preview #### Day 6: Expense Export + Invoice Overdue Detection **Expense export service:** `src/lib/services/expense-export.ts` ```typescript /** * Export expenses as CSV. * @param portId - Port UUID * @param filters - Same filters as list endpoint * @param columns - Which columns to include */ export async function exportCsv( portId: string, userId: string, filters: ListExpensesInput, columns?: string[], ): Promise; /** * Export expenses as PDF with receipt images. * Groups by category, includes receipt thumbnails. */ export async function exportPdf( portId: string, userId: string, filters: ListExpensesInput, ): Promise; /** * Parent company export (BR-043). * All selected expenses converted to EUR, 5% processing fee on EUR subtotal. * Includes receipt images. Configurable fee rate via system_settings. */ export async function exportParentCompany( portId: string, userId: string, filters: ListExpensesInput, ): Promise; ``` API routes: | Method | Path | File | |--------|------|------| | POST | `/api/v1/expenses/export/csv` | `src/app/api/v1/expenses/export/csv/route.ts` | | POST | `/api/v1/expenses/export/pdf` | `src/app/api/v1/expenses/export/pdf/route.ts` | | POST | `/api/v1/expenses/export/parent-company` | `src/app/api/v1/expenses/export/parent-company/route.ts` | **Invoice overdue detection (BR-044):** ```typescript // src/jobs/processors/invoice-overdue.ts { name: 'invoice-overdue-check', queue: 'maintenance', concurrency: 1, repeat: { pattern: '0 8 * * *' }, // Daily at 8 AM processor: async () => { const overdueInvoices = await db.select() .from(invoices) .where(and( eq(invoices.status, 'sent'), lt(invoices.dueDate, new Date()), )); for (const invoice of overdueInvoices) { await db.update(invoices) .set({ status: 'overdue', updatedAt: new Date() }) .where(eq(invoices.id, invoice.id)); await auditLog({ portId: invoice.portId, userId: 'system', action: 'update', entityType: 'invoice', entityId: invoice.id, changes: [{ field: 'status', oldValue: 'sent', newValue: 'overdue' }] }); // Notify invoice creator + admin await createNotification({ portId: invoice.portId, type: 'invoice_overdue', entityType: 'invoice', entityId: invoice.id, recipientUserIds: [invoice.createdBy, ...adminUserIds], }); } }, } ``` --- ### Stream D: File Management (Days 1–3) Stream D is the most independent — can start Day 1 in parallel with Stream A. #### Day 1: File Service + MinIO Integration **Service:** `src/lib/services/files.ts` ```typescript /** * Upload a file to MinIO and create metadata record. * Storage path: {portSlug}/{entity}/{entityId}/{uuid}.{ext} * NEVER uses user-provided filenames as storage paths (path traversal prevention). * * @param portId - Port UUID * @param userId - Uploading user ID * @param file - File buffer with metadata * @param context - What entity this file belongs to * @returns File record with presigned download URL * @throws {ValidationError} If MIME type not in allowlist or file too large */ export async function uploadFile( portId: string, userId: string, file: { buffer: Buffer; originalName: string; mimeType: string; size: number }, context: { entityType: string; entityId: string; category?: string }, ): Promise; /** * Generate presigned download URL for a file (15-minute expiry). * Verifies the requesting user has access to the parent entity. */ export async function getDownloadUrl(portId: string, fileId: string): Promise; /** * Generate presigned preview URL (for images/PDFs, 15-minute expiry). */ export async function getPreviewUrl(portId: string, fileId: string): Promise; /** * Update file metadata (rename, recategorize). * Original name in DB changes; MinIO key stays the same (UUID-based). */ export async function updateFile( portId: string, userId: string, fileId: string, data: { name?: string; category?: string }, ): Promise; /** * Delete a file. Removes from MinIO + deletes DB record. * @throws {ConflictError} If file is referenced by another entity (BR-091) */ export async function deleteFile(portId: string, userId: string, fileId: string): Promise; /** * List files with filtering. */ export async function listFiles(portId: string, query: ListFilesInput): Promise; ``` **File validation constants:** ```typescript // src/lib/constants/file-validation.ts export const ALLOWED_MIME_TYPES = new Set([ // Images 'image/jpeg', 'image/png', 'image/gif', 'image/webp', // Documents 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // Spreadsheets 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ]); export const MIME_TO_EXT: Record = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'application/pdf': 'pdf', 'application/msword': 'doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', 'application/vnd.ms-excel': 'xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', }; export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB ``` **MinIO storage path generation:** ```typescript // src/lib/services/storage.ts import { randomUUID } from 'crypto'; /** * Generate a safe storage key. NEVER uses user input in path. * Format: {portSlug}/{entity}/{entityId}/{uuid}.{ext} */ export function generateStorageKey( portSlug: string, entityType: string, entityId: string, mimeType: string, ): string { const ext = MIME_TO_EXT[mimeType] ?? 'bin'; const uuid = randomUUID(); return `${portSlug}/${entityType}/${entityId}/${uuid}.${ext}`; } ``` **Filename sanitization:** ```typescript /** * Sanitize user-provided filename for display/storage in DB. * Strips path separators, null bytes, unicode control characters. */ export function sanitizeFilename(name: string): string { return name .replace(/[/\\]/g, '_') // path separators .replace(/\0/g, '') // null bytes .replace(/[\x00-\x1f\x7f]/g, '') // control characters .replace(/[<>:"|?*]/g, '_') // Windows-illegal chars .trim() .slice(0, 255); // max length } ``` **API routes:** | Method | Path | File | | ------ | ----------------------------- | --------------------------------------------- | | GET | `/api/v1/files` | `src/app/api/v1/files/route.ts` | | POST | `/api/v1/files/upload` | `src/app/api/v1/files/upload/route.ts` | | GET | `/api/v1/files/[id]` | `src/app/api/v1/files/[id]/route.ts` | | GET | `/api/v1/files/[id]/download` | `src/app/api/v1/files/[id]/download/route.ts` | | GET | `/api/v1/files/[id]/preview` | `src/app/api/v1/files/[id]/preview/route.ts` | | PATCH | `/api/v1/files/[id]` | `src/app/api/v1/files/[id]/route.ts` | | DELETE | `/api/v1/files/[id]` | `src/app/api/v1/files/[id]/route.ts` | #### Day 2: File Browser UI `src/app/(dashboard)/[portSlug]/documents/page.tsx` Note: the URL path is `/documents` (not `/files`) per `13-UI-PAGE-MAP.md`. ``` — breadcrumb path, upload button, view toggle
— left sidebar: folder navigation — main area: files in current folder or — alternative table view
— drag-and-drop overlay (appears on drag) — lightbox for images, embedded viewer for PDFs
``` **Components:** `src/components/files/folder-tree.tsx` - Tree structure: Port root → clients/ (auto-generated) → {client folders} → {categories} - Expandable nodes, selected folder highlighted - Right-click context menu: New Folder, Rename, Delete (empty folders only) `src/components/files/file-grid.tsx` - Card layout with file icon/thumbnail, name, size, upload date - Click to preview (images/PDFs) or download (other types) - Right-click context menu: Download, Rename, Move, Delete `src/components/files/file-upload-zone.tsx` - Drag-and-drop with visual overlay - Progress bars per file - Auto-detects category from upload context - Multi-file upload support `src/components/files/file-preview-dialog.tsx` - Dialog/sheet component - Images: rendered via `` with presigned URL - PDFs: embedded via `