Files
pn-new-crm/competing-plans/blessed/L2-BUSINESS-WORKFLOWS.md
Matt 67d7e6e3d5
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled
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>
2026-03-26 11:52:51 +01:00

2511 lines
95 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# L2: Business Workflows — Competing Plan (Claude Code)
**Duration:** Days 1016 (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` §26, `08-API-ENDPOINT-CATALOG.md` §49, `09-BUSINESS-RULES.md` BR-001BR-092/BR-130BR-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<any, any, any> | typeof db;
/**
* Run callback in a transaction, or use the provided tx if already in one.
* Prevents nested transaction issues.
*/
export async function withTransaction<T>(
txOrDb: TxOrDb | undefined,
callback: (tx: TxOrDb) => Promise<T>,
): Promise<T> {
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<typeof BerthStatusTrigger>;
export const BerthStatusRuleMode = z.enum(['auto', 'suggest', 'off']);
export type BerthStatusRuleMode = z.infer<typeof BerthStatusRuleMode>;
export const BerthStatus = z.enum(['available', 'under_offer', 'sold']);
export type BerthStatus = z.infer<typeof BerthStatus>;
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<BerthStatusRule[]>;
/**
* 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<RuleEvaluationResult | null>;
/**
* 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<void>;
```
**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<PipelineStage, string> = {
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<PipelineStage, string> = {
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<LeadCategory, string> = {
general_interest: 'General Interest',
specific_qualified: 'Specific Qualified',
hot_lead: 'Hot Lead',
};
```
---
### Stream A: Interest Management (Days 14)
#### 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<PipelineStage, number>;
}
/**
* 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<InterestListResult>;
/**
* 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<InterestDetail>;
/**
* 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<Interest>;
/**
* 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<Interest>;
/**
* 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<Interest>;
```
**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`
```
<PipelineBoard> — main container
<PipelineBoardHeader> — filter bar + view toggle
<DndContext> — @dnd-kit/core context
{PIPELINE_STAGES.map(stage => (
<PipelineColumn — droppable column
key={stage}
stage={stage}
count={stageCounts[stage]}
>
<SortableContext>
{interests.map(interest => (
<PipelineCard — draggable card
key={interest.id}
interest={interest}
/>
))}
</SortableContext>
</PipelineColumn>
))}
</DndContext>
</PipelineBoard>
```
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<void>;
```
**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:
```
<InterestDetailPage>
<InterestDetailHeader> — client name, berth badge, stage badge, actions dropdown
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="notes">Notes</TabsTrigger>
<TabsTrigger value="documents">Documents</TabsTrigger>
<TabsTrigger value="timeline">Timeline</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<InterestOverviewTab> — editable fields, milestones, berth info, recommendations
<MilestoneTimeline />
<InterestFieldsForm /> — inline editable fields
<BerthCard /> — linked berth summary or "Link berth" button
<RecommendationPanel /> — berth recommendations
</InterestOverviewTab>
</TabsContent>
<TabsContent value="notes">
<NotesThread entityType="interest" entityId={id} />
</TabsContent>
<TabsContent value="documents">
{/* Stream B builds this */}
<DocumentList interestId={id} />
</TabsContent>
<TabsContent value="timeline">
<ActivityTimeline entityType="interest" entityId={id} />
</TabsContent>
</Tabs>
</InterestDetailPage>
```
**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<RecommendationResult[]>;
/**
* 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<void>;
/**
* Add a manual berth recommendation.
*/
export async function addManualRecommendation(
portId: string,
interestId: string,
berthId: string,
userId: string,
): Promise<void>;
```
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<WaitingListEntry>;
/**
* Remove a client from a berth's waiting list.
*/
export async function removeFromWaitingList(
portId: string,
userId: string,
entryId: string,
): Promise<void>;
/**
* 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<void>;
/**
* 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<void>;
```
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 25)
#### 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<DocumentListResult>;
/**
* 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<DocumentDetail>;
/**
* 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<Document>;
/**
* 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<Document>;
```
**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<DocumentDetail>;
```
**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<DocumensoDocument>;
async addRecipient(documentId: string, data: RecipientInput): Promise<Recipient>;
async sendDocument(documentId: string): Promise<void>;
async getDocument(documentId: string): Promise<DocumensoDocument>;
async getRecipients(documentId: string): Promise<Recipient[]>;
async downloadSignedPdf(documentId: string): Promise<Buffer>;
async sendReminder(documentId: string, recipientId: string): Promise<void>;
}
```
**@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<string, string>[],
): Promise<Buffer>;
```
**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`
```
<SigningProgress document={document}>
<ProgressBar value={signedCount / totalSigners * 100} />
{document.signers.map(signer => (
<SignerRow
key={signer.id}
name={signer.signerName}
email={signer.signerEmail}
role={signer.signerRole}
status={signer.status} — Badge: 'Pending' | 'Signed' | 'Declined'
signedAt={signer.signedAt}
isNextSigner={signer.signingOrder === nextOrder}
onResend={() => resendReminder(signer.id)}
onView={() => window.open(signer.signingUrl, '_blank')}
/>
))}
<SigningEventLog events={document.events} />
</SigningProgress>
```
**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<void>;
```
**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 36)
#### 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<Expense>;
/**
* List expenses with pagination and filtering.
*/
export async function listExpenses(
portId: string,
query: ListExpensesInput,
): Promise<ExpenseListResult>;
/**
* Get single expense with receipt file presigned URLs.
*/
export async function getExpense(portId: string, expenseId: string): Promise<ExpenseDetail>;
/**
* Update expense. Recalculates amount_usd if amount or currency changed.
*/
export async function updateExpense(
portId: string,
userId: string,
expenseId: string,
data: UpdateExpenseInput,
): Promise<Expense>;
/**
* 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<void>;
```
**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<number | null>;
/**
* 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<void>;
```
**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:
```
<ScanPage>
<ScanHeader> — "Scan Receipt" title, back button
<CameraCapture /> — uses navigator.mediaDevices.getUserMedia
or
<FileUploadZone /> — drag-and-drop or file picker for gallery
<ScanPreview> — shows captured/uploaded image
<Button>Scan</Button> — sends to API
</ScanPreview>
<ScanResults> — after API returns
<EditableField label="Establishment" value={extracted.establishment} />
<EditableField label="Date" value={extracted.date} />
<EditableField label="Amount" value={extracted.amount} />
<EditableField label="Currency" value={extracted.currency} />
<LineItemsTable items={extracted.lineItems} />
<Button>Save as Expense</Button>
</ScanResults>
</ScanPage>
```
**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<ScanResult> {
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<InvoiceDetail>;
/**
* 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<string>;
/**
* 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<void>;
/**
* Record payment against an invoice.
* Updates invoice status and payment fields.
*/
export async function recordPayment(
portId: string,
userId: string,
invoiceId: string,
data: RecordPaymentInput,
): Promise<Invoice>;
/**
* List invoices with filtering.
*/
export async function listInvoices(
portId: string,
query: ListInvoicesInput,
): Promise<InvoiceListResult>;
/**
* Get invoice detail with line items and linked expenses.
*/
export async function getInvoice(portId: string, invoiceId: string): Promise<InvoiceDetail>;
```
**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<string> {
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<Buffer>;
/**
* Export expenses as PDF with receipt images.
* Groups by category, includes receipt thumbnails.
*/
export async function exportPdf(
portId: string,
userId: string,
filters: ListExpensesInput,
): Promise<Buffer>;
/**
* 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<Buffer>;
```
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 13)
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<FileRecord>;
/**
* 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<string>;
/**
* Generate presigned preview URL (for images/PDFs, 15-minute expiry).
*/
export async function getPreviewUrl(portId: string, fileId: string): Promise<string>;
/**
* 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<FileRecord>;
/**
* 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<void>;
/**
* List files with filtering.
*/
export async function listFiles(portId: string, query: ListFilesInput): Promise<FileListResult>;
```
**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<string, string> = {
'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`.
```
<FileBrowserPage>
<FileBrowserHeader> — breadcrumb path, upload button, view toggle
<div className="flex gap-4">
<FolderTree /> — left sidebar: folder navigation
<FileGrid /> — main area: files in current folder
or
<FileTable /> — alternative table view
</div>
<FileUploadZone /> — drag-and-drop overlay (appears on drag)
<FilePreviewDialog /> — lightbox for images, embedded viewer for PDFs
</FileBrowserPage>
```
**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 `<img>` with presigned URL
- PDFs: embedded via `<iframe>` or `<object>` with presigned URL
- Other types: download button only
**Socket.io events:**
- `file:uploaded` → `{ fileId, fileName, entityType, entityId, portId }`
- `file:deleted` → `{ fileId, portId }`
- `file:moved` → `{ fileId, oldPath, newPath, portId }`
#### Day 3: Client Files Tab + Folder Management
**Client files tab** — add to client detail page from L1:
`src/components/clients/client-files-tab.tsx`
- Shows files where `context.entityType = 'client'` and `context.entityId = clientId`
- Upload directly from client context (auto-sets entityType/entityId)
- Same preview/download capabilities as file browser
**Interest files** — similar pattern on interest detail:
`src/components/interests/interest-files-tab.tsx`
- Files linked to the interest
**Folder CRUD:**
| Method | Path | File |
| ------ | --------------------------------- | ------------------------------------------------- |
| POST | `/api/v1/files/folders` | `src/app/api/v1/files/folders/route.ts` |
| PATCH | `/api/v1/files/folders/[...path]` | `src/app/api/v1/files/folders/[...path]/route.ts` |
| DELETE | `/api/v1/files/folders/[...path]` | `src/app/api/v1/files/folders/[...path]/route.ts` |
Folders are virtual — represented by the MinIO key prefix structure. The `files` table records track which "folder" a file belongs to via the storage key prefix. Folder operations create/rename/delete MinIO prefixes.
---
### Day 7: Integration + Record PDF Export + Document Templates (Stretch)
This day handles features the baseline omitted entirely.
#### Record PDF Export
`src/lib/services/record-export.ts`
```typescript
/**
* Export client summary as branded PDF (BR-150, BR-151).
* Includes: contacts, vessel details, interests summary, recent 20 timeline entries, files list.
*/
export async function exportClientPdf(portId: string, clientId: string): Promise<Buffer>;
/**
* Export berth spec sheet as branded PDF.
* Includes: full specs, pricing in all currencies, linked interests, maintenance summary.
*/
export async function exportBerthPdf(portId: string, berthId: string): Promise<Buffer>;
/**
* Export interest summary as branded PDF.
* Includes: client info, berth info, pipeline stage, milestones, notes, recent timeline.
*/
export async function exportInterestPdf(portId: string, interestId: string): Promise<Buffer>;
```
PDF templates:
- `src/lib/pdf/templates/client-summary-template.ts`
- `src/lib/pdf/templates/berth-spec-template.ts`
- `src/lib/pdf/templates/interest-summary-template.ts`
All use port branding: logo + primary color from `ports` settings. Consistent layout across record types (BR-150).
API routes:
| Method | Path | File |
|--------|------|------|
| POST | `/api/v1/clients/[id]/export-pdf` | `src/app/api/v1/clients/[id]/export-pdf/route.ts` |
| POST | `/api/v1/berths/[id]/export-pdf` | `src/app/api/v1/berths/[id]/export-pdf/route.ts` |
| POST | `/api/v1/interests/[id]/export-pdf` | `src/app/api/v1/interests/[id]/export-pdf/route.ts` |
#### Document Templates (Partial — complete in L3)
Since document templates require TipTap integration (which is an L3 feature per `12-IMPLEMENTATION-SEQUENCE.md`), Day 7 builds the backend foundation:
**Service:** `src/lib/services/document-templates.ts`
```typescript
/**
* List document templates for a port.
*/
export async function listTemplates(
portId: string,
query: ListTemplatesInput,
): Promise<TemplateListResult>;
/**
* Create document template.
* Permission: admin.manage_forms (BR-141)
*/
export async function createTemplate(
portId: string,
userId: string,
data: CreateTemplateInput,
): Promise<DocumentTemplate>;
/**
* Get available merge fields grouped by source.
* Sources: client, interest, berth, port
*/
export async function getMergeFields(): Promise<MergeFieldDefinition[]>;
/**
* Resolve merge fields in template body.
* Replaces {{entity.field}} tokens with actual values.
* Missing required fields → validation error with list (BR-140).
* Missing optional fields → replaced with empty string.
*/
export async function resolveTemplate(
portId: string,
templateId: string,
context: { clientId?: string; interestId?: string; berthId?: string },
): Promise<string>;
/**
* Generate document from template.
* Resolves merge fields, renders HTML → PDF via @pdfme, uploads to MinIO.
*/
export async function generateFromTemplate(
portId: string,
userId: string,
templateId: string,
context: { clientId?: string; interestId?: string; berthId?: string },
): Promise<{ document: Document; fileId: string }>;
```
API routes:
| Method | Path | File |
|--------|------|------|
| GET | `/api/v1/document-templates` | `src/app/api/v1/document-templates/route.ts` |
| POST | `/api/v1/document-templates` | `src/app/api/v1/document-templates/route.ts` |
| GET | `/api/v1/document-templates/[id]` | `src/app/api/v1/document-templates/[id]/route.ts` |
| PATCH | `/api/v1/document-templates/[id]` | `src/app/api/v1/document-templates/[id]/route.ts` |
| DELETE | `/api/v1/document-templates/[id]` | `src/app/api/v1/document-templates/[id]/route.ts` |
| GET | `/api/v1/document-templates/merge-fields` | `src/app/api/v1/document-templates/merge-fields/route.ts` |
| POST | `/api/v1/document-templates/[id]/generate` | `src/app/api/v1/document-templates/[id]/generate/route.ts` |
| POST | `/api/v1/document-templates/[id]/generate-and-send` | `src/app/api/v1/document-templates/[id]/generate-and-send/route.ts` |
| POST | `/api/v1/document-templates/[id]/generate-and-sign` | `src/app/api/v1/document-templates/[id]/generate-and-sign/route.ts` |
TipTap template editor UI is deferred to L3 where TipTap is set up for all 3 contexts.
---
## 3. Code-Ready Details
### Middleware Chain per Route Group
| Route Group | Middleware Chain |
| ------------------------------ | ------------------------------------------------------------------------------ |
| `/api/v1/interests/*` | `withAuth → withPortScope → withPermission('interests', action) → handler` |
| `/api/v1/documents/*` | `withAuth → withPortScope → withPermission('documents', action) → handler` |
| `/api/v1/expenses/*` | `withAuth → withPortScope → withPermission('expenses', action) → handler` |
| `/api/v1/invoices/*` | `withAuth → withPortScope → withPermission('invoices', action) → handler` |
| `/api/v1/files/*` | `withAuth → withPortScope → withPermission('files', action) → handler` |
| `/api/v1/document-templates/*` | `withAuth → withPortScope → withPermission('admin', 'manage_forms') → handler` |
| `/api/public/interests` | `withRateLimit(10/min) → withCors(PUBLIC_SITE_URL) → handler` |
| `/api/webhooks/documenso` | `withWebhookSignature → handler` (no auth — signature-verified) |
### TanStack Query Key Structure
| Key | Endpoint | Cache Time |
| ---------------------------------------------- | --------------- | ---------- |
| `['interests', portId, filters]` | List interests | 30s stale |
| `['interests', portId, id]` | Get interest | 1m stale |
| `['interests', portId, id, 'recommendations']` | Recommendations | 5m stale |
| `['interests', portId, id, 'notes']` | Notes | 30s stale |
| `['interests', portId, id, 'documents']` | Documents | 30s stale |
| `['interests', portId, id, 'timeline']` | Timeline | 1m stale |
| `['documents', portId, filters]` | List documents | 30s stale |
| `['documents', portId, id]` | Get document | 30s stale |
| `['expenses', portId, filters]` | List expenses | 30s stale |
| `['expenses', portId, id]` | Get expense | 1m stale |
| `['invoices', portId, filters]` | List invoices | 30s stale |
| `['invoices', portId, id]` | Get invoice | 1m stale |
| `['files', portId, filters]` | List files | 30s stale |
| `['berths', portId, id, 'waiting-list']` | Waiting list | 1m stale |
| `['currency-rates']` | Exchange rates | 6h stale |
### Zustand Slices
```typescript
// src/lib/stores/pipeline-store.ts
interface PipelineStore {
viewMode: 'table' | 'board';
setViewMode: (mode: 'table' | 'board') => void;
boardFilters: PipelineBoardFilters;
setBoardFilters: (filters: Partial<PipelineBoardFilters>) => void;
}
// src/lib/stores/file-browser-store.ts
interface FileBrowserStore {
viewMode: 'grid' | 'list';
setViewMode: (mode: 'grid' | 'list') => void;
currentFolder: string | null;
setCurrentFolder: (folder: string | null) => void;
selectedFiles: Set<string>;
toggleFileSelection: (fileId: string) => void;
clearSelection: () => void;
}
```
### shadcn/ui Components Used
| Component | Where Used |
| ------------------------------------------------------------ | ------------------------------------------------------------------------ |
| `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent` | Interest detail, invoice detail |
| `Badge` | Pipeline stage, lead category, document type, document status, file type |
| `Progress` | Signing progress bar, file upload progress |
| `Dialog`, `DialogContent`, `DialogHeader` | Expense form, file preview, confirmation dialogs |
| `Sheet` | File preview panel (side drawer) |
| `DropdownMenu` | Actions menus, context menus |
| `Command`, `CommandInput`, `CommandList` | Client search combobox, berth search |
| `Card`, `CardHeader`, `CardContent` | Pipeline cards, recommendation cards, expense cards |
| `Table`, `TableHeader`, `TableBody`, `TableRow`, `TableCell` | All list views, line items |
| `DatePicker` (custom) | Expense date, invoice due date, milestone dates |
| `Select`, `SelectContent`, `SelectItem` | Category dropdowns, currency selector, payment terms |
| `Tooltip` | Stage badges hover, recommendation scores, milestone details |
| `AlertDialog` | Destructive confirmations (archive interest, delete file) |
| `Toast` / `Sonner` | Berth status suggestions, success/error notifications |
### CSS/Tailwind Patterns
Pipeline board columns:
```tsx
<div className="flex gap-3 overflow-x-auto pb-4">
{/* Each column */}
<div className="flex-shrink-0 w-72 bg-muted/50 rounded-lg p-3">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">{label}</h3>
<Badge variant="secondary">{count}</Badge>
</div>
<div className="space-y-2 min-h-[200px]">{/* Pipeline cards */}</div>
</div>
</div>
```
Pipeline card:
```tsx
<Card className="cursor-grab active:cursor-grabbing hover:ring-2 hover:ring-ring/20 transition-shadow">
<CardContent className="p-3 space-y-2">
<p className="font-medium text-sm truncate">{clientName}</p>
<p className="text-xs text-muted-foreground truncate">{vesselName}</p>
{berth && (
<Badge variant="outline" className="text-xs">
{berth.mooringNumber}
</Badge>
)}
<div className="flex items-center justify-between">
<Badge className={LEAD_CATEGORY_COLORS[leadCategory]}>{leadCategoryLabel}</Badge>
<span className="text-xs text-muted-foreground">{daysInStage}d</span>
</div>
</CardContent>
</Card>
```
---
## 4. Acceptance Criteria
### Interest Management (Stream A)
1. Interest CRUD (create, read, update, archive, restore) with all fields from schema
2. Interest list with pagination, filtering by stage/category/client/berth/source, and sorting
3. Pipeline Kanban board with 8 columns matching BR-010 stages (`open` through `completed`)
4. Drag-and-drop between pipeline stages with optimistic updates
5. Stage change fires berth status rules engine for relevant triggers
6. Berth linking with configurable rules engine (7 triggers × 3 modes from BR-001)
7. Berth unlinking with conditional status reset when no active interests remain
8. Berth status suggestion dialog for `suggest` mode rules
9. Auto rules apply silently with audit log for `auto` mode
10. Lead category auto-promotion per BR-011 (vessel dimensions → specific_qualified)
11. Interest notes with 15-minute edit window (BR-033)
12. Interest tags (add/remove)
13. Milestone timeline showing all 7 date milestones
14. Berth recommendation engine: filter by clearance, score by 5 factors, return top 10
15. Manual berth recommendation addition
16. "Link this berth" from recommendation results
17. Waiting list CRUD per berth using `client_id` (not interest_id)
18. Waiting list reordering with position auto-assignment
19. Public interest registration API at `/api/public/interests` with rate limiting
20. BR-030: email match → new interest under existing client
21. BR-031: fuzzy duplicate detection on new client creation (score ≥ 0.7 → alert)
### EOI & Documents (Stream B)
22. Document CRUD with all fields from `documents` schema
23. EOI generation with BR-020 prerequisite validation
24. EOI PDF generation via @pdfme with port branding
25. Documenso integration: create document, add 3 sequential signers, send
26. Documenso webhook receiver with HMAC signature verification
27. Webhook event deduplication via `signature_hash` unique index
28. Handle RECIPIENT_SIGNED: update signer status, notify next signer
29. Handle DOCUMENT_COMPLETED: download PDF, store in MinIO, update milestones (BR-133)
30. Handle DOCUMENT_EXPIRED: update status, notify interest owner
31. Signing progress UI with per-signer status, resend button, signing URL link
32. Signing reminders: time-gated (9:00-16:00), cooldown, per-interest toggle (BR-023)
33. Fallback Documenso polling every 6 hours
34. Manual document upload bypass with milestone auto-population (BR-013)
### Expenses & Invoices (Stream C)
35. Expense CRUD with receipt file upload
36. Multi-currency support with automatic USD conversion (BR-040)
37. Currency rate refresh from Frankfurter API every 6 hours with cache fallback
38. Receipt scanner: image → OpenAI Vision → extracted data → user review → save
39. Receipt scanner uses current vision model (gpt-4o), not deprecated model
40. Invoice creation from expenses or manual line items (BR-045 transaction integrity)
41. Invoice auto-numbering INV-YYYYMM-### with advisory lock (BR-041)
42. Net 10 discount auto-calculation (BR-042)
43. Invoice PDF generation via @pdfme with port branding
44. Invoice email sending via Poste.io (BullMQ queued)
45. Payment recording on invoices
46. Invoice overdue detection: daily job, status update, notification (BR-044)
47. Expense export: CSV, PDF with receipts, parent company format (BR-043)
### File Management (Stream D)
48. File upload with MIME type allowlist, max 50MB, filename sanitization
49. UUID-based storage paths (never user input in path — path traversal prevention)
50. Presigned download/preview URLs with 15-minute expiry
51. File browser with folder tree, grid/list toggle
52. Drag-and-drop file upload with progress indicators
53. Inline preview: images (lightbox), PDFs (embedded viewer)
54. Client files tab on client detail page
55. Folder create/rename/delete
56. File reference integrity check before deletion (BR-091)
57. All file operations audited and emitting socket events
### Record PDF Export
58. Client summary PDF export with port branding
59. Berth spec sheet PDF export with port branding
60. Interest summary PDF export with port branding
### Cross-Cutting
61. All mutations write to `audit_logs` with before/after values
62. All mutations emit Socket.io events to `port:{portId}` room
63. All API endpoints validated with Zod (input validation before any business logic)
64. Port scoping enforced at service layer on every query
65. Error responses follow Security Guidelines §13 (no stack traces, no internal paths)
---
## 5. Self-Review Checklist
### Before calling L2 complete:
- [ ] All 8 pipeline stages match BR-010 exactly: `open`, `details_sent`, `in_communication`, `visited`, `signed_eoi_nda`, `deposit_10pct`, `contract`, `completed`
- [ ] All route paths use `(dashboard)/[portSlug]/` — zero instances of `(crm)/`
- [ ] All component paths use `src/components/{entity}/` — zero instances of `domain/`
- [ ] Berth status rules engine handles all 7 triggers from BR-001, not just interest link/unlink
- [ ] Waiting list uses `client_id` (not `interest_id`) per schema
- [ ] All file storage paths are UUID-based, never user-provided filenames
- [ ] MinIO presigned URLs expire in 15 minutes per Security Guidelines §7.1
- [ ] Documenso webhook signature verified before processing
- [ ] Document event deduplication via `signature_hash` unique index
- [ ] EOI prerequisites validated per BR-020 (name, email, yacht dims, berth linked)
- [ ] EOI signing order is sequential: client (1) → developer (2) → sales/approver (3) per BR-021
- [ ] Invoice numbering uses advisory lock to prevent race conditions
- [ ] Receipt scanner uses current OpenAI model, not deprecated gpt-4-vision-preview
- [ ] Currency rates cached with 24h fallback if Frankfurter API is down
- [ ] All business rules referenced by BR number are implemented and have unit tests
- [ ] Every API endpoint has Zod validation on both input and output shape
- [ ] No raw SQL — all queries through Drizzle ORM parameterized
- [ ] No `dangerouslySetInnerHTML` except with DOMPurify-sanitized content
- [ ] Error messages never leak internal details (Security Guidelines §13)
- [ ] Sensitive fields (email, phone) in audit logs are masked (Security Guidelines §4.3)
- [ ] Integration test: create interest → link berth → generate EOI → receive webhook → verify milestones → create expense → generate invoice → upload file
---
## 6. Merge Strategy
Recommended merge order for the 4 streams:
1. **Stream D (Files)** first — independent, provides file upload infrastructure needed by all other streams
2. **Stream A (Interests)** second — depends on L1 clients + berths, provides interests needed by documents
3. **Stream B (Documents)** third — depends on interests from Stream A
4. **Stream C (Expenses/Invoices)** fourth — mostly independent but shares file upload and PDF generation patterns
5. **Day 7 work** last — record PDF export and document template backend
**Integration test after merge:** Create interest → link berth (verify rule fires) → generate EOI → simulate webhook → verify milestone auto-population → create expense with receipt scan → generate invoice from expense → export PDF → upload manual file → verify audit trail complete.
---
## 7. Edge Cases & Error Handling
### Race Conditions
- **Invoice numbering:** Advisory lock prevents two concurrent invoice creates from getting the same number
- **Berth linking:** Two users linking different interests to the same berth simultaneously — both succeed, both trigger rules evaluation independently. The berth status change is idempotent (setting `under_offer` twice is fine).
- **Webhook dedup:** `signature_hash` unique index prevents double-processing even if Documenso retries and fallback poll both fire
### External Service Failures
- **Documenso down during EOI send:** Transaction rolls back all DB changes. MinIO file cleanup via try/catch. User sees: "Document signing service temporarily unavailable."
- **MinIO down during file upload:** Return 503 with "File storage temporarily unavailable." No partial records created.
- **OpenAI Vision down during receipt scan:** Return 503 with "Receipt scanning is temporarily unavailable. You can enter the expense manually." UI shows manual form.
- **Frankfurter API down during rate refresh:** Use last cached rates (BR-040 allows this). If no rates at all: expense saved without conversion, flagged for manual rate entry.
### Data Integrity
- **Expense linked to invoice, then archived:** Blocked — throw ConflictError "Cannot archive expense linked to invoice {number}"
- **File referenced by expense receipt, then deleted:** Blocked — throw ConflictError per BR-091
- **Interest archived while EOI pending signatures:** EOI not auto-cancelled (Documenso continues). Interest is archived but documents remain accessible. Salesperson must manually cancel if needed.
- **Multiple EOIs on same interest:** BR-020 blocks if existing EOI exists, unless user explicitly overrides. Override creates a new document; old one remains for audit trail.
### Concurrent Edits
- **Two users editing same interest:** Last-write-wins with audit trail. Both changes are logged. TanStack Query refetch on mutation ensures UI stays current.
- **Drag-and-drop with stale data:** Optimistic update may be reverted if server-side validation fails (e.g., permission denied). Toast notification explains the revert.
---
## Codex Addenda — Merged from Competing Plan Review
### 1. File Deletion: Actual Delete, Not Soft-Delete
Per BR-091, file deletion **removes the MinIO object and the `files` row** once no references remain. This is not a soft-delete. The reference-service must check:
- `documents.file_id`
- `documents.signed_file_id`
- `invoices.pdf_file_id`
- `email_messages.raw_file_id`
- Arrays: `expenses.receipt_file_ids`, `berth_maintenance_log.photo_file_ids`, `email_messages.attachment_file_ids`
Delete on a referenced file returns 409 with the referencing entity summary.
### 2. UUID-Only Storage Keys
Storage keys must be UUID-based. User input (original filenames) must **never** be part of the MinIO object path. Original filenames are stored in the `files` table for display only. Pattern: `{portSlug}/{entity}/{entityId}/{uuid}.{ext}`.
### 3. EOI as Document Workflow (Not Special-Case Branch)
Treat EOI generation as a document workflow sitting on the same `documents`, `document_signers`, and `document_events` backbone — not as a special-case branch. One consolidated `generateEoiDocument()` service handles creation.
### 4. Webhook Idempotency via Signature Hash
Use `document_events.signature_hash` unique index for Documenso webhook idempotency, not in-memory locks. This ensures duplicate webhook deliveries are safely ignored at the database level.
### 5. Parent-Company Export Path
The locked API catalog places the parent-company export at `/api/expenses/export/parent-company`, not under `/api/admin/`. Use the exact catalog path.
### 6. Invoice Creation Schema Refinement
Add a `.refine()` to the invoice creation schema requiring either `expenseIds` or `lineItems`:
```ts
.refine((data) => data.expenseIds.length > 0 || data.lineItems.length > 0, {
message: "Invoice requires expenseIds or lineItems",
});
```
### 7. Financial Edge Cases
- `net10` discount rate comes from `system_settings`, not a hardcoded 2%.
- Parent-company export converts to EUR before adding the fee, per BR-043.
- Missing exchange rate does not block expense creation; it sets a warning state for manual follow-up.
- Invoice creation returns 409 if any selected expense is already linked through `invoice_expenses`.
### 8. Document Edge Cases
- Sequential signing order is always client → developer → sales/approver.
- Client must have at least one email contact; do not read email directly from `clients`.
- EOI generation returns 409 if manual-uploaded EOI already exists and override is not explicit.
- Invalid Documenso signature returns 401 and is audit-logged as a security event.