2511 lines
95 KiB
Markdown
2511 lines
95 KiB
Markdown
|
|
# 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<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 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<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 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<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 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<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 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<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.
|