2026-04-28 02:35:36 +02:00
|
|
|
import { and, count, eq, gte, inArray, lt, lte, ne, sql, exists } from 'drizzle-orm';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
2026-04-28 02:12:05 +02:00
|
|
|
import {
|
|
|
|
|
documents,
|
|
|
|
|
documentSigners,
|
|
|
|
|
documentEvents,
|
|
|
|
|
documentWatchers,
|
|
|
|
|
files,
|
|
|
|
|
} from '@/lib/db/schema/documents';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { interests } from '@/lib/db/schema/interests';
|
|
|
|
|
import { clients } from '@/lib/db/schema/clients';
|
|
|
|
|
import { ports } from '@/lib/db/schema/ports';
|
|
|
|
|
import { buildListQuery } from '@/lib/db/query-builder';
|
|
|
|
|
import { createAuditLog } from '@/lib/audit';
|
|
|
|
|
import { diffEntity } from '@/lib/entity-diff';
|
|
|
|
|
import { NotFoundError, ValidationError, ConflictError } from '@/lib/errors';
|
|
|
|
|
import { emitToRoom } from '@/lib/socket/server';
|
2026-03-26 12:06:18 +01:00
|
|
|
import { minioClient, buildStoragePath } from '@/lib/minio';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { env } from '@/lib/env';
|
|
|
|
|
import { logger } from '@/lib/logger';
|
|
|
|
|
import { evaluateRule } from '@/lib/services/berth-rules-engine';
|
|
|
|
|
import {
|
|
|
|
|
createDocument as documensoCreate,
|
|
|
|
|
sendDocument as documensoSend,
|
|
|
|
|
downloadSignedPdf,
|
2026-04-28 02:22:04 +02:00
|
|
|
voidDocument as documensoVoid,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
} from '@/lib/services/documenso-client';
|
|
|
|
|
import type {
|
|
|
|
|
CreateDocumentInput,
|
|
|
|
|
UpdateDocumentInput,
|
|
|
|
|
ListDocumentsInput,
|
|
|
|
|
} from '@/lib/validators/documents';
|
|
|
|
|
|
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
interface AuditMeta {
|
|
|
|
|
userId: string;
|
|
|
|
|
portId: string;
|
|
|
|
|
ipAddress: string;
|
|
|
|
|
userAgent: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-28 02:35:36 +02:00
|
|
|
import { documentWatchers as documentWatchersTable } from '@/lib/db/schema/documents';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
2026-04-28 02:35:36 +02:00
|
|
|
const NON_SIGNATURE_TYPES = [
|
|
|
|
|
'welcome_letter',
|
|
|
|
|
'handover_checklist',
|
|
|
|
|
'acknowledgment',
|
|
|
|
|
'correspondence',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function buildHubTabFilters(
|
|
|
|
|
tab: ListDocumentsInput['tab'],
|
|
|
|
|
currentUserEmail: string | undefined,
|
|
|
|
|
): ReturnType<typeof and>[] {
|
|
|
|
|
const filters: ReturnType<typeof and>[] = [];
|
|
|
|
|
if (!tab || tab === 'all') return filters;
|
|
|
|
|
|
|
|
|
|
switch (tab) {
|
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.
PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
right-rail, three-tab page (active/dismissed/resolved), socket-driven
invalidation. Bell lazy-loads list on popover open to keep cold pages
fast in non-dashboard routes.
PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count
surfaces in tab label.
PR7 Interests-by-berth tab on berth detail — replaces the stub.
PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow
banner on detail w/ Merge / Not-a-duplicate, transactional merge
consolidates receipts and archives the source.
PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
its own (scanner) group with no dashboard chrome, dynamic per-port
manifest, OpenAI + Claude provider abstraction, admin OCR settings
page (port-level + super-admin global default w/ opt-in fallback),
test-connection endpoint, manual-entry fallback when no key is
configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
existing GIN index, cursor pagination, filters for entity/action/user
/date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
cleanly without their gate envs so CI stays green.
Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
|
|
|
case 'eoi_queue':
|
|
|
|
|
// EOI documents currently in-flight (drafted, sent, or partially signed).
|
|
|
|
|
// Used by the dedicated tab on the documents hub to triage EOI signing
|
|
|
|
|
// pipeline volume separate from the all-doc-types view.
|
|
|
|
|
filters.push(eq(documents.documentType, 'eoi'));
|
|
|
|
|
filters.push(inArray(documents.status, ['draft', 'sent', 'partially_signed']));
|
|
|
|
|
break;
|
2026-04-28 02:35:36 +02:00
|
|
|
case 'awaiting_them':
|
|
|
|
|
// "awaiting them" = pending signers other than the current user.
|
|
|
|
|
// Without a known caller email we cannot make that distinction, so
|
|
|
|
|
// short-circuit to empty rather than silently widen the result set.
|
|
|
|
|
if (!currentUserEmail) {
|
|
|
|
|
filters.push(sql`1 = 0`);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
filters.push(inArray(documents.status, ['sent', 'partially_signed']));
|
|
|
|
|
filters.push(
|
|
|
|
|
exists(
|
|
|
|
|
db
|
|
|
|
|
.select({ x: sql`1` })
|
|
|
|
|
.from(documentSigners)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(documentSigners.documentId, documents.id),
|
|
|
|
|
eq(documentSigners.status, 'pending'),
|
|
|
|
|
ne(documentSigners.signerEmail, currentUserEmail),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
break;
|
|
|
|
|
case 'awaiting_me':
|
|
|
|
|
if (!currentUserEmail) {
|
|
|
|
|
// Without a current-user email there is no concept of "awaiting me"
|
|
|
|
|
filters.push(sql`1 = 0`);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
filters.push(
|
|
|
|
|
exists(
|
|
|
|
|
db
|
|
|
|
|
.select({ x: sql`1` })
|
|
|
|
|
.from(documentSigners)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(documentSigners.documentId, documents.id),
|
|
|
|
|
eq(documentSigners.status, 'pending'),
|
|
|
|
|
eq(documentSigners.signerEmail, currentUserEmail),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
break;
|
|
|
|
|
case 'completed':
|
|
|
|
|
filters.push(inArray(documents.status, ['completed', 'signed']));
|
|
|
|
|
break;
|
|
|
|
|
case 'expired':
|
|
|
|
|
// Either explicitly expired, or in-flight past their expiry date.
|
|
|
|
|
// (Documents schema doesn't yet have an `expires_at` column, so for
|
|
|
|
|
// now this is just status='expired' — extend when expiry lands.)
|
|
|
|
|
filters.push(eq(documents.status, 'expired'));
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
return filters;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ListDocumentsExtra {
|
|
|
|
|
/** Email of the calling user — used by hub tab filtering for "awaiting me". */
|
|
|
|
|
currentUserEmail?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function listDocuments(
|
|
|
|
|
portId: string,
|
|
|
|
|
query: ListDocumentsInput,
|
|
|
|
|
extra: ListDocumentsExtra = {},
|
|
|
|
|
) {
|
|
|
|
|
const {
|
|
|
|
|
page,
|
|
|
|
|
limit,
|
|
|
|
|
sort,
|
|
|
|
|
order,
|
|
|
|
|
search,
|
|
|
|
|
interestId,
|
|
|
|
|
clientId,
|
|
|
|
|
documentType,
|
|
|
|
|
status,
|
|
|
|
|
tab,
|
|
|
|
|
watcherUserId,
|
|
|
|
|
signatureOnly,
|
|
|
|
|
sentSince,
|
|
|
|
|
sentUntil,
|
|
|
|
|
} = query;
|
|
|
|
|
|
|
|
|
|
const filters: ReturnType<typeof and>[] = [];
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
if (interestId) filters.push(eq(documents.interestId, interestId));
|
|
|
|
|
if (clientId) filters.push(eq(documents.clientId, clientId));
|
|
|
|
|
if (documentType) filters.push(eq(documents.documentType, documentType));
|
|
|
|
|
if (status) filters.push(eq(documents.status, status));
|
2026-04-28 02:35:36 +02:00
|
|
|
if (sentSince) filters.push(gte(documents.createdAt, new Date(sentSince)));
|
|
|
|
|
if (sentUntil) filters.push(lte(documents.createdAt, new Date(sentUntil)));
|
|
|
|
|
if (signatureOnly === true) {
|
|
|
|
|
filters.push(
|
|
|
|
|
sql`${documents.documentType} NOT IN ('welcome_letter','handover_checklist','acknowledgment','correspondence')`,
|
|
|
|
|
);
|
|
|
|
|
} else if (signatureOnly === false) {
|
|
|
|
|
// Pass-through, no extra filter needed.
|
|
|
|
|
}
|
|
|
|
|
if (watcherUserId) {
|
|
|
|
|
filters.push(
|
|
|
|
|
exists(
|
|
|
|
|
db
|
|
|
|
|
.select({ x: sql`1` })
|
|
|
|
|
.from(documentWatchersTable)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(documentWatchersTable.documentId, documents.id),
|
|
|
|
|
eq(documentWatchersTable.userId, watcherUserId),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
filters.push(...buildHubTabFilters(tab, extra.currentUserEmail));
|
|
|
|
|
|
|
|
|
|
void NON_SIGNATURE_TYPES;
|
|
|
|
|
void lt;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
const sortColumn =
|
refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.
Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.
Caller cleanup (zero behavioral change to remaining flows):
- Drops the legacy `generateEoi` flow entirely (route, service function,
pdfme template, validator schema). The dual-path generate-and-sign
service from PR 11 has fully replaced it; the route was no longer
wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
`yachts` via `interest.yachtId` instead of the dropped
`client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
lookup (direct + active company memberships); interest-summary fetches
yacht via `interest.yachtId`. Both PDF templates updated to read
yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
`search-result-item`, `use-search` hook, `types/domain.ts`,
`search.service` — drop the companyName badge / sub-label / typed
field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.
Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
|
|
|
sort === 'title'
|
|
|
|
|
? documents.title
|
|
|
|
|
: sort === 'status'
|
|
|
|
|
? documents.status
|
|
|
|
|
: sort === 'documentType'
|
|
|
|
|
? documents.documentType
|
|
|
|
|
: documents.createdAt;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
return buildListQuery({
|
|
|
|
|
table: documents,
|
|
|
|
|
portIdColumn: documents.portId,
|
|
|
|
|
portId,
|
|
|
|
|
idColumn: documents.id,
|
|
|
|
|
updatedAtColumn: documents.updatedAt,
|
|
|
|
|
searchColumns: [documents.title],
|
|
|
|
|
searchTerm: search,
|
2026-04-28 02:35:36 +02:00
|
|
|
filters: filters.filter(Boolean) as Parameters<typeof buildListQuery>[0]['filters'],
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
sort: sort ? { column: sortColumn, direction: order } : undefined,
|
|
|
|
|
page,
|
|
|
|
|
pageSize: limit,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 02:35:36 +02:00
|
|
|
// ─── Hub tab counts ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface HubTabCounts {
|
|
|
|
|
all: number;
|
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.
PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
right-rail, three-tab page (active/dismissed/resolved), socket-driven
invalidation. Bell lazy-loads list on popover open to keep cold pages
fast in non-dashboard routes.
PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count
surfaces in tab label.
PR7 Interests-by-berth tab on berth detail — replaces the stub.
PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow
banner on detail w/ Merge / Not-a-duplicate, transactional merge
consolidates receipts and archives the source.
PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
its own (scanner) group with no dashboard chrome, dynamic per-port
manifest, OpenAI + Claude provider abstraction, admin OCR settings
page (port-level + super-admin global default w/ opt-in fallback),
test-connection endpoint, manual-entry fallback when no key is
configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
existing GIN index, cursor pagination, filters for entity/action/user
/date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
cleanly without their gate envs so CI stays green.
Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
|
|
|
eoi_queue: number;
|
2026-04-28 02:35:36 +02:00
|
|
|
awaiting_them: number;
|
|
|
|
|
awaiting_me: number;
|
|
|
|
|
completed: number;
|
|
|
|
|
expired: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Compute hub tab counts in a single roundtrip per tab. Uses
|
|
|
|
|
* idx_docs_status_port for cheap aggregation.
|
|
|
|
|
*/
|
|
|
|
|
export async function getHubTabCounts(
|
|
|
|
|
portId: string,
|
|
|
|
|
currentUserEmail: string | undefined,
|
|
|
|
|
): Promise<HubTabCounts> {
|
|
|
|
|
async function tabCount(tab: ListDocumentsInput['tab']): Promise<number> {
|
|
|
|
|
const filters: ReturnType<typeof and>[] = [eq(documents.portId, portId)];
|
|
|
|
|
filters.push(...buildHubTabFilters(tab, currentUserEmail));
|
|
|
|
|
const [row] = await db
|
|
|
|
|
.select({ count: count() })
|
|
|
|
|
.from(documents)
|
|
|
|
|
.where(and(...filters));
|
|
|
|
|
return row?.count ?? 0;
|
|
|
|
|
}
|
|
|
|
|
|
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.
PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
right-rail, three-tab page (active/dismissed/resolved), socket-driven
invalidation. Bell lazy-loads list on popover open to keep cold pages
fast in non-dashboard routes.
PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count
surfaces in tab label.
PR7 Interests-by-berth tab on berth detail — replaces the stub.
PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow
banner on detail w/ Merge / Not-a-duplicate, transactional merge
consolidates receipts and archives the source.
PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
its own (scanner) group with no dashboard chrome, dynamic per-port
manifest, OpenAI + Claude provider abstraction, admin OCR settings
page (port-level + super-admin global default w/ opt-in fallback),
test-connection endpoint, manual-entry fallback when no key is
configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
existing GIN index, cursor pagination, filters for entity/action/user
/date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
cleanly without their gate envs so CI stays green.
Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
|
|
|
const [all, eoi_queue, awaiting_them, awaiting_me, completed, expired] = await Promise.all([
|
2026-04-28 02:35:36 +02:00
|
|
|
tabCount('all'),
|
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.
PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
right-rail, three-tab page (active/dismissed/resolved), socket-driven
invalidation. Bell lazy-loads list on popover open to keep cold pages
fast in non-dashboard routes.
PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count
surfaces in tab label.
PR7 Interests-by-berth tab on berth detail — replaces the stub.
PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow
banner on detail w/ Merge / Not-a-duplicate, transactional merge
consolidates receipts and archives the source.
PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
its own (scanner) group with no dashboard chrome, dynamic per-port
manifest, OpenAI + Claude provider abstraction, admin OCR settings
page (port-level + super-admin global default w/ opt-in fallback),
test-connection endpoint, manual-entry fallback when no key is
configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
existing GIN index, cursor pagination, filters for entity/action/user
/date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
cleanly without their gate envs so CI stays green.
Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
|
|
|
tabCount('eoi_queue'),
|
2026-04-28 02:35:36 +02:00
|
|
|
tabCount('awaiting_them'),
|
|
|
|
|
tabCount('awaiting_me'),
|
|
|
|
|
tabCount('completed'),
|
|
|
|
|
tabCount('expired'),
|
|
|
|
|
]);
|
|
|
|
|
|
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.
PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
right-rail, three-tab page (active/dismissed/resolved), socket-driven
invalidation. Bell lazy-loads list on popover open to keep cold pages
fast in non-dashboard routes.
PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count
surfaces in tab label.
PR7 Interests-by-berth tab on berth detail — replaces the stub.
PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow
banner on detail w/ Merge / Not-a-duplicate, transactional merge
consolidates receipts and archives the source.
PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
its own (scanner) group with no dashboard chrome, dynamic per-port
manifest, OpenAI + Claude provider abstraction, admin OCR settings
page (port-level + super-admin global default w/ opt-in fallback),
test-connection endpoint, manual-entry fallback when no key is
configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
existing GIN index, cursor pagination, filters for entity/action/user
/date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
cleanly without their gate envs so CI stays green.
Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
|
|
|
return { all, eoi_queue, awaiting_them, awaiting_me, completed, expired };
|
2026-04-28 02:35:36 +02:00
|
|
|
}
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// ─── Get by ID ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getDocumentById(id: string, portId: string) {
|
|
|
|
|
const doc = await db.query.documents.findFirst({
|
|
|
|
|
where: and(eq(documents.id, id), eq(documents.portId, portId)),
|
|
|
|
|
with: { signers: true },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!doc) throw new NotFoundError('Document');
|
|
|
|
|
return doc;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Create ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.
Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.
Caller cleanup (zero behavioral change to remaining flows):
- Drops the legacy `generateEoi` flow entirely (route, service function,
pdfme template, validator schema). The dual-path generate-and-sign
service from PR 11 has fully replaced it; the route was no longer
wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
`yachts` via `interest.yachtId` instead of the dropped
`client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
lookup (direct + active company memberships); interest-summary fetches
yacht via `interest.yachtId`. Both PDF templates updated to read
yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
`search-result-item`, `use-search` hook, `types/domain.ts`,
`search.service` — drop the companyName badge / sub-label / typed
field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.
Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
|
|
|
export async function createDocument(portId: string, data: CreateDocumentInput, meta: AuditMeta) {
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const [doc] = await db
|
|
|
|
|
.insert(documents)
|
|
|
|
|
.values({
|
|
|
|
|
portId,
|
|
|
|
|
interestId: data.interestId ?? null,
|
|
|
|
|
clientId: data.clientId ?? null,
|
|
|
|
|
documentType: data.documentType,
|
|
|
|
|
title: data.title,
|
|
|
|
|
notes: data.notes ?? null,
|
|
|
|
|
status: 'draft',
|
|
|
|
|
createdBy: meta.userId,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'create',
|
|
|
|
|
entityType: 'document',
|
|
|
|
|
entityId: doc!.id,
|
|
|
|
|
newValue: { documentType: doc!.documentType, title: doc!.title },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'document:created', { documentId: doc!.id });
|
|
|
|
|
|
|
|
|
|
return doc!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Update ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function updateDocument(
|
|
|
|
|
id: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
data: UpdateDocumentInput,
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const existing = await getDocumentById(id, portId);
|
|
|
|
|
|
|
|
|
|
const updates: Partial<typeof documents.$inferInsert> = {};
|
|
|
|
|
if (data.title !== undefined) updates.title = data.title;
|
|
|
|
|
if (data.notes !== undefined) updates.notes = data.notes;
|
|
|
|
|
if (data.status !== undefined) updates.status = data.status;
|
|
|
|
|
updates.updatedAt = new Date();
|
|
|
|
|
|
|
|
|
|
const [updated] = await db
|
|
|
|
|
.update(documents)
|
|
|
|
|
.set(updates)
|
|
|
|
|
.where(and(eq(documents.id, id), eq(documents.portId, portId)))
|
|
|
|
|
.returning();
|
|
|
|
|
|
2026-03-26 12:06:18 +01:00
|
|
|
diffEntity(existing, updated!);
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'update',
|
|
|
|
|
entityType: 'document',
|
|
|
|
|
entityId: id,
|
|
|
|
|
oldValue: existing as unknown as Record<string, unknown>,
|
|
|
|
|
newValue: updated as unknown as Record<string, unknown>,
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'document:updated', { documentId: id });
|
|
|
|
|
|
|
|
|
|
return updated!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Delete ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function deleteDocument(id: string, portId: string, meta: AuditMeta) {
|
|
|
|
|
const existing = await getDocumentById(id, portId);
|
|
|
|
|
|
|
|
|
|
if (['sent', 'partially_signed'].includes(existing.status)) {
|
|
|
|
|
throw new ConflictError('Cannot delete a document that is currently in signing process');
|
|
|
|
|
}
|
|
|
|
|
|
refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.
Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.
Caller cleanup (zero behavioral change to remaining flows):
- Drops the legacy `generateEoi` flow entirely (route, service function,
pdfme template, validator schema). The dual-path generate-and-sign
service from PR 11 has fully replaced it; the route was no longer
wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
`yachts` via `interest.yachtId` instead of the dropped
`client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
lookup (direct + active company memberships); interest-summary fetches
yacht via `interest.yachtId`. Both PDF templates updated to read
yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
`search-result-item`, `use-search` hook, `types/domain.ts`,
`search.service` — drop the companyName badge / sub-label / typed
field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.
Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
|
|
|
await db.delete(documents).where(and(eq(documents.id, id), eq(documents.portId, portId)));
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'delete',
|
|
|
|
|
entityType: 'document',
|
|
|
|
|
entityId: id,
|
|
|
|
|
oldValue: { title: existing.title, status: existing.status },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'document:deleted', { documentId: id });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Send for Signing (BR-021) ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function sendForSigning(documentId: string, portId: string, meta: AuditMeta) {
|
|
|
|
|
const doc = await getDocumentById(documentId, portId);
|
|
|
|
|
if (!doc.fileId) throw new ValidationError('Document has no associated file');
|
|
|
|
|
if (doc.status !== 'draft') throw new ConflictError('Document is not in draft status');
|
|
|
|
|
|
|
|
|
|
// Fetch interest + client to build signers
|
|
|
|
|
const interest = doc.interestId
|
|
|
|
|
? await db.query.interests.findFirst({ where: eq(interests.id, doc.interestId) })
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
const client = doc.clientId
|
|
|
|
|
? await db.query.clients.findFirst({
|
|
|
|
|
where: eq(clients.id, doc.clientId),
|
|
|
|
|
with: { contacts: true },
|
|
|
|
|
})
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
if (!client) throw new ValidationError('Document has no associated client');
|
|
|
|
|
|
refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.
Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.
Caller cleanup (zero behavioral change to remaining flows):
- Drops the legacy `generateEoi` flow entirely (route, service function,
pdfme template, validator schema). The dual-path generate-and-sign
service from PR 11 has fully replaced it; the route was no longer
wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
`yachts` via `interest.yachtId` instead of the dropped
`client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
lookup (direct + active company memberships); interest-summary fetches
yacht via `interest.yachtId`. Both PDF templates updated to read
yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
`search-result-item`, `use-search` hook, `types/domain.ts`,
`search.service` — drop the companyName badge / sub-label / typed
field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.
Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
|
|
|
const emailContact = (
|
|
|
|
|
client.contacts as Array<{ channel: string; value: string }> | undefined
|
|
|
|
|
)?.find((c) => c.channel === 'email');
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
if (!emailContact?.value) throw new ValidationError('Client has no email contact');
|
|
|
|
|
|
|
|
|
|
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
|
|
|
|
if (!port) throw new NotFoundError('Port');
|
|
|
|
|
|
|
|
|
|
// BR-021: Create 3 signers — client (1), developer (2), sales/approver (3)
|
|
|
|
|
const signerRecords = await db
|
|
|
|
|
.insert(documentSigners)
|
|
|
|
|
.values([
|
|
|
|
|
{
|
|
|
|
|
documentId,
|
|
|
|
|
signerName: client.fullName,
|
|
|
|
|
signerEmail: emailContact.value,
|
|
|
|
|
signerRole: 'client',
|
|
|
|
|
signingOrder: 1,
|
|
|
|
|
status: 'pending',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
documentId,
|
|
|
|
|
signerName: port.name,
|
|
|
|
|
signerEmail: `developer@${port.slug}.com`,
|
|
|
|
|
signerRole: 'developer',
|
|
|
|
|
signingOrder: 2,
|
|
|
|
|
status: 'pending',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
documentId,
|
|
|
|
|
signerName: `${port.name} Sales`,
|
|
|
|
|
signerEmail: `sales@${port.slug}.com`,
|
|
|
|
|
signerRole: 'approver',
|
|
|
|
|
signingOrder: 3,
|
|
|
|
|
status: 'pending',
|
|
|
|
|
},
|
|
|
|
|
])
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
// Get file from MinIO and base64 encode
|
|
|
|
|
const fileRecord = await db.query.files.findFirst({ where: eq(files.id, doc.fileId) });
|
|
|
|
|
if (!fileRecord) throw new NotFoundError('File');
|
|
|
|
|
|
|
|
|
|
const fileStream = await minioClient.getObject(env.MINIO_BUCKET, fileRecord.storagePath);
|
|
|
|
|
const chunks: Buffer[] = [];
|
|
|
|
|
for await (const chunk of fileStream) {
|
|
|
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
|
|
|
}
|
|
|
|
|
const pdfBuffer = Buffer.concat(chunks);
|
|
|
|
|
const pdfBase64 = pdfBuffer.toString('base64');
|
|
|
|
|
|
|
|
|
|
// Create document in Documenso + send
|
|
|
|
|
const documensoDoc = await documensoCreate(doc.title, pdfBase64, [
|
|
|
|
|
{ name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 },
|
|
|
|
|
{ name: port.name, email: `developer@${port.slug}.com`, role: 'SIGNER', signingOrder: 2 },
|
refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.
Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.
Caller cleanup (zero behavioral change to remaining flows):
- Drops the legacy `generateEoi` flow entirely (route, service function,
pdfme template, validator schema). The dual-path generate-and-sign
service from PR 11 has fully replaced it; the route was no longer
wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
`yachts` via `interest.yachtId` instead of the dropped
`client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
lookup (direct + active company memberships); interest-summary fetches
yacht via `interest.yachtId`. Both PDF templates updated to read
yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
`search-result-item`, `use-search` hook, `types/domain.ts`,
`search.service` — drop the companyName badge / sub-label / typed
field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.
Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
|
|
|
{
|
|
|
|
|
name: `${port.name} Sales`,
|
|
|
|
|
email: `sales@${port.slug}.com`,
|
|
|
|
|
role: 'SIGNER',
|
|
|
|
|
signingOrder: 3,
|
|
|
|
|
},
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await documensoSend(documensoDoc.id);
|
|
|
|
|
|
|
|
|
|
// Update signer records with signing URLs from Documenso response
|
|
|
|
|
for (const docSigner of documensoDoc.recipients) {
|
|
|
|
|
const localSigner = signerRecords.find((s) => s.signerEmail === docSigner.email);
|
|
|
|
|
if (localSigner) {
|
|
|
|
|
await db
|
|
|
|
|
.update(documentSigners)
|
|
|
|
|
.set({
|
|
|
|
|
signingUrl: docSigner.signingUrl ?? null,
|
|
|
|
|
embeddedUrl: docSigner.embeddedUrl ?? null,
|
|
|
|
|
})
|
|
|
|
|
.where(eq(documentSigners.id, localSigner.id));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update document status
|
|
|
|
|
await db
|
|
|
|
|
.update(documents)
|
|
|
|
|
.set({ status: 'sent', documensoId: documensoDoc.id, updatedAt: new Date() })
|
|
|
|
|
.where(eq(documents.id, documentId));
|
|
|
|
|
|
|
|
|
|
// Update interest if linked
|
|
|
|
|
if (interest) {
|
|
|
|
|
await db
|
|
|
|
|
.update(interests)
|
|
|
|
|
.set({
|
|
|
|
|
documensoId: documensoDoc.id,
|
|
|
|
|
dateEoiSent: new Date(),
|
|
|
|
|
eoiStatus: 'waiting_for_signatures',
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
})
|
|
|
|
|
.where(eq(interests.id, interest.id));
|
|
|
|
|
|
|
|
|
|
// Trigger berth rules
|
|
|
|
|
void evaluateRule('eoi_sent', interest.id, portId, meta);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create document event
|
|
|
|
|
await db.insert(documentEvents).values({
|
|
|
|
|
documentId,
|
|
|
|
|
eventType: 'sent',
|
|
|
|
|
eventData: { documensoId: documensoDoc.id },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'update',
|
|
|
|
|
entityType: 'document',
|
|
|
|
|
entityId: documentId,
|
|
|
|
|
newValue: { status: 'sent', documensoId: documensoDoc.id },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.
Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.
Caller cleanup (zero behavioral change to remaining flows):
- Drops the legacy `generateEoi` flow entirely (route, service function,
pdfme template, validator schema). The dual-path generate-and-sign
service from PR 11 has fully replaced it; the route was no longer
wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
`yachts` via `interest.yachtId` instead of the dropped
`client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
lookup (direct + active company memberships); interest-summary fetches
yacht via `interest.yachtId`. Both PDF templates updated to read
yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
`search-result-item`, `use-search` hook, `types/domain.ts`,
`search.service` — drop the companyName badge / sub-label / typed
field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.
Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
|
|
|
emitToRoom(`port:${portId}`, 'document:sent', {
|
|
|
|
|
documentId,
|
|
|
|
|
type: doc.documentType,
|
|
|
|
|
signerCount: 3,
|
|
|
|
|
documensoId: documensoDoc.id,
|
|
|
|
|
});
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
return await getDocumentById(documentId, portId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Upload Signed Manually (BR-013) ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function uploadSignedManually(
|
|
|
|
|
documentId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
fileData: { buffer: Buffer; originalName: string; mimeType: string; size: number },
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const doc = await getDocumentById(documentId, portId);
|
|
|
|
|
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
|
|
|
|
if (!port) throw new NotFoundError('Port');
|
|
|
|
|
|
|
|
|
|
// Store the signed file
|
|
|
|
|
const fileId = crypto.randomUUID();
|
|
|
|
|
const storagePath = buildStoragePath(port.slug, 'eoi-signed', documentId, fileId, 'pdf');
|
|
|
|
|
|
refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.
Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.
Caller cleanup (zero behavioral change to remaining flows):
- Drops the legacy `generateEoi` flow entirely (route, service function,
pdfme template, validator schema). The dual-path generate-and-sign
service from PR 11 has fully replaced it; the route was no longer
wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
`yachts` via `interest.yachtId` instead of the dropped
`client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
lookup (direct + active company memberships); interest-summary fetches
yacht via `interest.yachtId`. Both PDF templates updated to read
yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
`search-result-item`, `use-search` hook, `types/domain.ts`,
`search.service` — drop the companyName badge / sub-label / typed
field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.
Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
|
|
|
await minioClient.putObject(env.MINIO_BUCKET, storagePath, fileData.buffer, fileData.size, {
|
|
|
|
|
'Content-Type': fileData.mimeType,
|
|
|
|
|
});
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
const [fileRecord] = await db
|
|
|
|
|
.insert(files)
|
|
|
|
|
.values({
|
|
|
|
|
portId,
|
|
|
|
|
clientId: doc.clientId ?? null,
|
|
|
|
|
filename: fileData.originalName,
|
|
|
|
|
originalName: fileData.originalName,
|
|
|
|
|
mimeType: fileData.mimeType,
|
|
|
|
|
sizeBytes: String(fileData.size),
|
|
|
|
|
storagePath,
|
|
|
|
|
storageBucket: env.MINIO_BUCKET,
|
|
|
|
|
category: 'eoi',
|
|
|
|
|
uploadedBy: meta.userId,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
// Update document
|
|
|
|
|
await db
|
|
|
|
|
.update(documents)
|
|
|
|
|
.set({
|
|
|
|
|
signedFileId: fileRecord!.id,
|
|
|
|
|
status: 'completed',
|
|
|
|
|
isManualUpload: true,
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
})
|
|
|
|
|
.where(eq(documents.id, documentId));
|
|
|
|
|
|
|
|
|
|
// Update interest if linked and type is eoi
|
|
|
|
|
if (doc.interestId && doc.documentType === 'eoi') {
|
|
|
|
|
const interest = await db.query.interests.findFirst({
|
|
|
|
|
where: eq(interests.id, doc.interestId),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db
|
|
|
|
|
.update(interests)
|
|
|
|
|
.set({ eoiStatus: 'signed', dateEoiSigned: new Date(), updatedAt: new Date() })
|
|
|
|
|
.where(eq(interests.id, doc.interestId));
|
|
|
|
|
|
|
|
|
|
if (interest) {
|
|
|
|
|
void evaluateRule('eoi_signed', doc.interestId, portId, meta);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await db.insert(documentEvents).values({
|
|
|
|
|
documentId,
|
|
|
|
|
eventType: 'completed',
|
|
|
|
|
eventData: { isManualUpload: true, fileId: fileRecord!.id },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'update',
|
|
|
|
|
entityType: 'document',
|
|
|
|
|
entityId: documentId,
|
|
|
|
|
newValue: { status: 'completed', isManualUpload: true },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'document:completed', { documentId });
|
|
|
|
|
|
|
|
|
|
// Notify creator about manual completion
|
|
|
|
|
void import('@/lib/services/notifications.service').then(({ createNotification }) =>
|
|
|
|
|
createNotification({
|
|
|
|
|
portId,
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
type: 'document_signed',
|
|
|
|
|
title: 'Document marked as signed',
|
|
|
|
|
description: `"${doc.title}" has been manually uploaded as signed`,
|
|
|
|
|
link: `/documents/${documentId}`,
|
|
|
|
|
entityType: 'document',
|
|
|
|
|
entityId: documentId,
|
|
|
|
|
dedupeKey: `document:${documentId}:completed`,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return await getDocumentById(documentId, portId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── List Signers ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function listDocumentSigners(documentId: string, portId: string) {
|
|
|
|
|
await getDocumentById(documentId, portId); // verify access
|
|
|
|
|
|
|
|
|
|
return db.query.documentSigners.findMany({
|
|
|
|
|
where: eq(documentSigners.documentId, documentId),
|
|
|
|
|
orderBy: (ds, { asc }) => [asc(ds.signingOrder)],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── List Events ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function listDocumentEvents(documentId: string, portId: string) {
|
|
|
|
|
await getDocumentById(documentId, portId); // verify access
|
|
|
|
|
|
|
|
|
|
return db.query.documentEvents.findMany({
|
|
|
|
|
where: eq(documentEvents.documentId, documentId),
|
|
|
|
|
orderBy: (de, { desc }) => [desc(de.createdAt)],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Webhook Handlers ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function handleRecipientSigned(eventData: {
|
|
|
|
|
documentId: string;
|
|
|
|
|
recipientEmail: string;
|
|
|
|
|
signatureHash?: string;
|
|
|
|
|
}) {
|
|
|
|
|
const doc = await db.query.documents.findFirst({
|
|
|
|
|
where: eq(documents.documensoId, eventData.documentId),
|
|
|
|
|
});
|
|
|
|
|
if (!doc) {
|
|
|
|
|
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update signer status
|
|
|
|
|
const [signer] = await db
|
|
|
|
|
.update(documentSigners)
|
|
|
|
|
.set({ status: 'signed', signedAt: new Date() })
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(documentSigners.documentId, doc.id),
|
|
|
|
|
eq(documentSigners.signerEmail, eventData.recipientEmail),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
// Update document to partially_signed if eoi type
|
|
|
|
|
if (doc.documentType === 'eoi' && doc.status === 'sent') {
|
|
|
|
|
await db
|
|
|
|
|
.update(documents)
|
|
|
|
|
.set({ status: 'partially_signed', updatedAt: new Date() })
|
|
|
|
|
.where(eq(documents.id, doc.id));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await db.insert(documentEvents).values({
|
|
|
|
|
documentId: doc.id,
|
|
|
|
|
eventType: 'signed',
|
|
|
|
|
signerId: signer?.id ?? null,
|
|
|
|
|
signatureHash: eventData.signatureHash ?? null,
|
|
|
|
|
eventData: { recipientEmail: eventData.recipientEmail },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${doc.portId}`, 'document:signer:signed', {
|
|
|
|
|
documentId: doc.id,
|
|
|
|
|
signerEmail: eventData.recipientEmail,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.
Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.
Caller cleanup (zero behavioral change to remaining flows):
- Drops the legacy `generateEoi` flow entirely (route, service function,
pdfme template, validator schema). The dual-path generate-and-sign
service from PR 11 has fully replaced it; the route was no longer
wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
`yachts` via `interest.yachtId` instead of the dropped
`client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
lookup (direct + active company memberships); interest-summary fetches
yacht via `interest.yachtId`. Both PDF templates updated to read
yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
`search-result-item`, `use-search` hook, `types/domain.ts`,
`search.service` — drop the companyName badge / sub-label / typed
field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.
Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
|
|
|
export async function handleDocumentCompleted(eventData: { documentId: string }) {
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const doc = await db.query.documents.findFirst({
|
|
|
|
|
where: eq(documents.documensoId, eventData.documentId),
|
|
|
|
|
});
|
|
|
|
|
if (!doc) {
|
|
|
|
|
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// BR-022: Download signed PDF and store in MinIO
|
|
|
|
|
const port = await db.query.ports.findFirst({ where: eq(ports.id, doc.portId) });
|
|
|
|
|
if (!port) {
|
|
|
|
|
logger.error({ portId: doc.portId }, 'Port not found during document completion');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const signedPdfBuffer = await downloadSignedPdf(eventData.documentId);
|
|
|
|
|
const fileId = crypto.randomUUID();
|
|
|
|
|
const storagePath = buildStoragePath(port.slug, 'eoi-signed', doc.id, fileId, 'pdf');
|
|
|
|
|
|
|
|
|
|
await minioClient.putObject(
|
|
|
|
|
env.MINIO_BUCKET,
|
|
|
|
|
storagePath,
|
|
|
|
|
signedPdfBuffer,
|
|
|
|
|
signedPdfBuffer.length,
|
|
|
|
|
{ 'Content-Type': 'application/pdf' },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const [fileRecord] = await db
|
|
|
|
|
.insert(files)
|
|
|
|
|
.values({
|
|
|
|
|
portId: doc.portId,
|
|
|
|
|
clientId: doc.clientId ?? null,
|
|
|
|
|
filename: `signed-${doc.id}.pdf`,
|
|
|
|
|
originalName: `signed-${doc.id}.pdf`,
|
|
|
|
|
mimeType: 'application/pdf',
|
|
|
|
|
sizeBytes: String(signedPdfBuffer.length),
|
|
|
|
|
storagePath,
|
|
|
|
|
storageBucket: env.MINIO_BUCKET,
|
|
|
|
|
category: 'eoi',
|
|
|
|
|
uploadedBy: 'system',
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
await db
|
|
|
|
|
.update(documents)
|
|
|
|
|
.set({ status: 'completed', signedFileId: fileRecord!.id, updatedAt: new Date() })
|
|
|
|
|
.where(eq(documents.id, doc.id));
|
2026-04-28 02:45:05 +02:00
|
|
|
|
|
|
|
|
// Reservation agreements mirror their signed PDF onto
|
|
|
|
|
// berth_reservations.contractFileId so the portal "My Reservations" view
|
|
|
|
|
// can resolve the contract without joining through documents.
|
|
|
|
|
if (doc.documentType === 'reservation_agreement' && doc.reservationId) {
|
|
|
|
|
const { berthReservations } = await import('@/lib/db/schema/reservations');
|
|
|
|
|
await db
|
|
|
|
|
.update(berthReservations)
|
|
|
|
|
.set({ contractFileId: fileRecord!.id, updatedAt: new Date() })
|
|
|
|
|
.where(eq(berthReservations.id, doc.reservationId));
|
|
|
|
|
}
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
logger.error({ err, documentId: doc.id }, 'Failed to download/store signed PDF');
|
|
|
|
|
await db
|
|
|
|
|
.update(documents)
|
|
|
|
|
.set({ status: 'completed', updatedAt: new Date() })
|
|
|
|
|
.where(eq(documents.id, doc.id));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update interest if eoi type
|
|
|
|
|
if (doc.interestId && doc.documentType === 'eoi') {
|
|
|
|
|
const interest = await db.query.interests.findFirst({
|
|
|
|
|
where: eq(interests.id, doc.interestId),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db
|
|
|
|
|
.update(interests)
|
|
|
|
|
.set({ eoiStatus: 'signed', dateEoiSigned: new Date(), updatedAt: new Date() })
|
|
|
|
|
.where(eq(interests.id, doc.interestId));
|
|
|
|
|
|
|
|
|
|
if (interest) {
|
|
|
|
|
void evaluateRule('eoi_signed', doc.interestId, doc.portId, {
|
|
|
|
|
userId: 'system',
|
|
|
|
|
portId: doc.portId,
|
|
|
|
|
ipAddress: '0.0.0.0',
|
|
|
|
|
userAgent: 'webhook',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await db.insert(documentEvents).values({
|
|
|
|
|
documentId: doc.id,
|
|
|
|
|
eventType: 'completed',
|
|
|
|
|
eventData: { documensoId: eventData.documentId },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${doc.portId}`, 'document:completed', { documentId: doc.id });
|
|
|
|
|
|
|
|
|
|
// Notify the document creator about completion
|
|
|
|
|
if (doc.createdBy && doc.createdBy !== 'system') {
|
|
|
|
|
void import('@/lib/services/notifications.service').then(({ createNotification }) =>
|
|
|
|
|
createNotification({
|
|
|
|
|
portId: doc.portId,
|
|
|
|
|
userId: doc.createdBy!,
|
|
|
|
|
type: 'document_signed',
|
|
|
|
|
title: 'Document fully signed',
|
|
|
|
|
description: `"${doc.title}" has been signed by all parties`,
|
|
|
|
|
link: `/documents/${doc.id}`,
|
|
|
|
|
entityType: 'document',
|
|
|
|
|
entityId: doc.id,
|
|
|
|
|
dedupeKey: `document:${doc.id}:completed`,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.
Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.
Caller cleanup (zero behavioral change to remaining flows):
- Drops the legacy `generateEoi` flow entirely (route, service function,
pdfme template, validator schema). The dual-path generate-and-sign
service from PR 11 has fully replaced it; the route was no longer
wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
`yachts` via `interest.yachtId` instead of the dropped
`client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
lookup (direct + active company memberships); interest-summary fetches
yacht via `interest.yachtId`. Both PDF templates updated to read
yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
`search-result-item`, `use-search` hook, `types/domain.ts`,
`search.service` — drop the companyName badge / sub-label / typed
field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.
Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
|
|
|
export async function handleDocumentExpired(eventData: { documentId: string }) {
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const doc = await db.query.documents.findFirst({
|
|
|
|
|
where: eq(documents.documensoId, eventData.documentId),
|
|
|
|
|
});
|
|
|
|
|
if (!doc) {
|
|
|
|
|
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await db
|
|
|
|
|
.update(documents)
|
|
|
|
|
.set({ status: 'expired', updatedAt: new Date() })
|
|
|
|
|
.where(eq(documents.id, doc.id));
|
|
|
|
|
|
|
|
|
|
if (doc.interestId && doc.documentType === 'eoi') {
|
|
|
|
|
await db
|
|
|
|
|
.update(interests)
|
|
|
|
|
.set({ eoiStatus: 'expired', updatedAt: new Date() })
|
|
|
|
|
.where(eq(interests.id, doc.interestId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await db.insert(documentEvents).values({
|
|
|
|
|
documentId: doc.id,
|
|
|
|
|
eventType: 'expired',
|
|
|
|
|
eventData: { documensoId: eventData.documentId },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${doc.portId}`, 'document:expired', { documentId: doc.id });
|
|
|
|
|
}
|
2026-04-27 13:46:48 +02:00
|
|
|
|
|
|
|
|
export async function handleDocumentOpened(eventData: {
|
|
|
|
|
documentId: string;
|
|
|
|
|
recipientEmail: string;
|
|
|
|
|
signatureHash?: string;
|
|
|
|
|
}) {
|
|
|
|
|
const doc = await db.query.documents.findFirst({
|
|
|
|
|
where: eq(documents.documensoId, eventData.documentId),
|
|
|
|
|
});
|
|
|
|
|
if (!doc) {
|
|
|
|
|
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [signer] = await db
|
|
|
|
|
.select()
|
|
|
|
|
.from(documentSigners)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(documentSigners.documentId, doc.id),
|
|
|
|
|
eq(documentSigners.signerEmail, eventData.recipientEmail),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await db.insert(documentEvents).values({
|
|
|
|
|
documentId: doc.id,
|
|
|
|
|
eventType: 'viewed',
|
|
|
|
|
signerId: signer?.id ?? null,
|
|
|
|
|
signatureHash: eventData.signatureHash ?? null,
|
|
|
|
|
eventData: { recipientEmail: eventData.recipientEmail },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${doc.portId}`, 'document:signer:opened', {
|
|
|
|
|
documentId: doc.id,
|
|
|
|
|
signerEmail: eventData.recipientEmail,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function handleDocumentRejected(eventData: {
|
|
|
|
|
documentId: string;
|
|
|
|
|
recipientEmail?: string;
|
|
|
|
|
signatureHash?: string;
|
|
|
|
|
}) {
|
|
|
|
|
const doc = await db.query.documents.findFirst({
|
|
|
|
|
where: eq(documents.documensoId, eventData.documentId),
|
|
|
|
|
});
|
|
|
|
|
if (!doc) {
|
|
|
|
|
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let signerId: string | null = null;
|
|
|
|
|
if (eventData.recipientEmail) {
|
|
|
|
|
const [signer] = await db
|
|
|
|
|
.update(documentSigners)
|
|
|
|
|
.set({ status: 'declined' })
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(documentSigners.documentId, doc.id),
|
|
|
|
|
eq(documentSigners.signerEmail, eventData.recipientEmail),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.returning();
|
|
|
|
|
signerId = signer?.id ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await db
|
|
|
|
|
.update(documents)
|
|
|
|
|
.set({ status: 'rejected', updatedAt: new Date() })
|
|
|
|
|
.where(eq(documents.id, doc.id));
|
|
|
|
|
|
|
|
|
|
if (doc.interestId && doc.documentType === 'eoi') {
|
|
|
|
|
await db
|
|
|
|
|
.update(interests)
|
|
|
|
|
.set({ eoiStatus: 'rejected', updatedAt: new Date() })
|
|
|
|
|
.where(eq(interests.id, doc.interestId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await db.insert(documentEvents).values({
|
|
|
|
|
documentId: doc.id,
|
|
|
|
|
eventType: 'rejected',
|
|
|
|
|
signerId,
|
|
|
|
|
signatureHash: eventData.signatureHash ?? null,
|
|
|
|
|
eventData: { recipientEmail: eventData.recipientEmail ?? null },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${doc.portId}`, 'document:rejected', {
|
|
|
|
|
documentId: doc.id,
|
|
|
|
|
signerEmail: eventData.recipientEmail ?? null,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function handleDocumentCancelled(eventData: {
|
|
|
|
|
documentId: string;
|
|
|
|
|
signatureHash?: string;
|
|
|
|
|
}) {
|
|
|
|
|
const doc = await db.query.documents.findFirst({
|
|
|
|
|
where: eq(documents.documensoId, eventData.documentId),
|
|
|
|
|
});
|
|
|
|
|
if (!doc) {
|
|
|
|
|
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await db
|
|
|
|
|
.update(documents)
|
|
|
|
|
.set({ status: 'cancelled', updatedAt: new Date() })
|
|
|
|
|
.where(eq(documents.id, doc.id));
|
|
|
|
|
|
|
|
|
|
if (doc.interestId && doc.documentType === 'eoi') {
|
|
|
|
|
await db
|
|
|
|
|
.update(interests)
|
|
|
|
|
.set({ eoiStatus: 'cancelled', updatedAt: new Date() })
|
|
|
|
|
.where(eq(interests.id, doc.interestId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await db.insert(documentEvents).values({
|
|
|
|
|
documentId: doc.id,
|
|
|
|
|
eventType: 'cancelled',
|
|
|
|
|
signatureHash: eventData.signatureHash ?? null,
|
|
|
|
|
eventData: { documensoId: eventData.documentId },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${doc.portId}`, 'document:cancelled', { documentId: doc.id });
|
|
|
|
|
}
|
2026-04-28 02:12:05 +02:00
|
|
|
|
|
|
|
|
// ─── Phase A: hub + wizard surface (PR1 skeletons; bodies land in PRs 4-6) ────
|
|
|
|
|
|
|
|
|
|
export interface DocumentDetailWatcher {
|
|
|
|
|
userId: string;
|
|
|
|
|
addedBy: string;
|
|
|
|
|
addedAt: Date;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface DocumentDetail {
|
|
|
|
|
document: typeof documents.$inferSelect;
|
|
|
|
|
signers: (typeof documentSigners.$inferSelect)[];
|
|
|
|
|
events: (typeof documentEvents.$inferSelect)[];
|
|
|
|
|
watchers: DocumentDetailWatcher[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Single-roundtrip aggregator for the document detail page (PR5).
|
|
|
|
|
* Returns the document plus all signers, events (newest first), and watchers.
|
|
|
|
|
* Throws NotFoundError if the document is not in `portId`.
|
|
|
|
|
*/
|
|
|
|
|
export async function getDocumentDetail(id: string, portId: string): Promise<DocumentDetail> {
|
|
|
|
|
const document = await getDocumentById(id, portId);
|
|
|
|
|
|
|
|
|
|
const [signers, events, watchers] = await Promise.all([
|
|
|
|
|
db.query.documentSigners.findMany({
|
|
|
|
|
where: eq(documentSigners.documentId, id),
|
|
|
|
|
orderBy: (ds, { asc }) => [asc(ds.signingOrder)],
|
|
|
|
|
}),
|
|
|
|
|
db.query.documentEvents.findMany({
|
|
|
|
|
where: eq(documentEvents.documentId, id),
|
|
|
|
|
orderBy: (de, { desc }) => [desc(de.createdAt)],
|
|
|
|
|
}),
|
|
|
|
|
db
|
|
|
|
|
.select({
|
|
|
|
|
userId: documentWatchers.userId,
|
|
|
|
|
addedBy: documentWatchers.addedBy,
|
|
|
|
|
addedAt: documentWatchers.addedAt,
|
|
|
|
|
})
|
|
|
|
|
.from(documentWatchers)
|
|
|
|
|
.where(eq(documentWatchers.documentId, id)),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return { document, signers, events, watchers };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* User-initiated cancel of an in-flight document. Voids the doc in Documenso
|
|
|
|
|
* (when present), updates DB status, logs an event, emits socket. Webhook
|
|
|
|
|
* receiver also handles documenso-initiated cancellations via
|
|
|
|
|
* `handleDocumentCancelled`.
|
|
|
|
|
*
|
|
|
|
|
* The actual Documenso void call lands in PR2 (`documenso-client.voidDocument`);
|
|
|
|
|
* this skeleton updates DB state only.
|
|
|
|
|
*/
|
|
|
|
|
export async function cancelDocument(
|
|
|
|
|
documentId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
): Promise<typeof documents.$inferSelect> {
|
|
|
|
|
const existing = await getDocumentById(documentId, portId);
|
|
|
|
|
|
|
|
|
|
if (['completed', 'cancelled', 'rejected'].includes(existing.status)) {
|
|
|
|
|
throw new ConflictError(`Document is already ${existing.status}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 02:22:04 +02:00
|
|
|
// CRM is the system of record for cancellation status. A transient
|
|
|
|
|
// Documenso failure shouldn't block the user from marking the doc cancelled
|
|
|
|
|
// here — voidDocument already treats 404 as success, and the periodic
|
|
|
|
|
// webhook receiver will reconcile if the remote void eventually lands.
|
|
|
|
|
if (existing.documensoId) {
|
|
|
|
|
try {
|
|
|
|
|
await documensoVoid(existing.documensoId, portId);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.warn(
|
|
|
|
|
{ err, documentId, documensoId: existing.documensoId },
|
|
|
|
|
'Documenso void failed; cancelling locally anyway',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-28 02:12:05 +02:00
|
|
|
|
|
|
|
|
const [updated] = await db
|
|
|
|
|
.update(documents)
|
|
|
|
|
.set({ status: 'cancelled', updatedAt: new Date() })
|
|
|
|
|
.where(and(eq(documents.id, documentId), eq(documents.portId, portId)))
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
await db.insert(documentEvents).values({
|
|
|
|
|
documentId,
|
|
|
|
|
eventType: 'cancelled',
|
|
|
|
|
eventData: { initiatedBy: meta.userId },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'update',
|
|
|
|
|
entityType: 'document',
|
|
|
|
|
entityId: documentId,
|
|
|
|
|
oldValue: { status: existing.status },
|
|
|
|
|
newValue: { status: 'cancelled' },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'document:cancelled', { documentId });
|
|
|
|
|
|
|
|
|
|
return updated!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns prefilled email composer payload for the "Email signed PDF to all
|
|
|
|
|
* signatories" action on the document detail page.
|
|
|
|
|
*
|
|
|
|
|
* Available for `status='completed' && signedFileId !== null`.
|
|
|
|
|
*
|
|
|
|
|
* Body content (from per-port `signed_doc_completion` template), full
|
|
|
|
|
* sender-resolution, and watcher-cc helpers land alongside PR8 (email
|
|
|
|
|
* composer with attachments). For PR1 this returns the minimal correct
|
|
|
|
|
* recipients + auto-attachment shape so detail-page integration tests can
|
|
|
|
|
* assert against it.
|
|
|
|
|
*/
|
|
|
|
|
export interface ComposeSignedDocEmailResult {
|
|
|
|
|
to: string[];
|
|
|
|
|
cc: string[];
|
|
|
|
|
subject: string;
|
|
|
|
|
body: string;
|
|
|
|
|
attachments: Array<{ fileId: string; filename?: string }>;
|
|
|
|
|
defaultSenderType: 'system' | 'user';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function composeSignedDocEmail(
|
|
|
|
|
documentId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
): Promise<ComposeSignedDocEmailResult> {
|
|
|
|
|
const doc = await getDocumentById(documentId, portId);
|
|
|
|
|
|
|
|
|
|
if (doc.status !== 'completed') {
|
|
|
|
|
throw new ConflictError('Document is not completed');
|
|
|
|
|
}
|
|
|
|
|
if (!doc.signedFileId) {
|
|
|
|
|
throw new ValidationError('Document has no signed PDF');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const signers = await db
|
|
|
|
|
.select({ email: documentSigners.signerEmail })
|
|
|
|
|
.from(documentSigners)
|
|
|
|
|
.where(eq(documentSigners.documentId, documentId));
|
|
|
|
|
|
|
|
|
|
const dedupedRecipients = Array.from(new Set(signers.map((s) => s.email)));
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
to: dedupedRecipients,
|
|
|
|
|
cc: [],
|
|
|
|
|
subject: `Signed ${doc.documentType.replace(/_/g, ' ')} — ${doc.title}`,
|
|
|
|
|
body: '',
|
|
|
|
|
attachments: [{ fileId: doc.signedFileId }],
|
|
|
|
|
defaultSenderType: 'system',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 02:39:46 +02:00
|
|
|
// ─── Watchers (PR5) ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function listDocumentWatchers(documentId: string, portId: string) {
|
|
|
|
|
await getDocumentById(documentId, portId); // port-scope check
|
|
|
|
|
return db
|
|
|
|
|
.select({
|
|
|
|
|
userId: documentWatchers.userId,
|
|
|
|
|
addedBy: documentWatchers.addedBy,
|
|
|
|
|
addedAt: documentWatchers.addedAt,
|
|
|
|
|
})
|
|
|
|
|
.from(documentWatchers)
|
|
|
|
|
.where(eq(documentWatchers.documentId, documentId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function addDocumentWatcher(
|
|
|
|
|
documentId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
userId: string,
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
): Promise<{ userId: string; addedAt: Date }> {
|
|
|
|
|
await getDocumentById(documentId, portId);
|
|
|
|
|
const [row] = await db
|
|
|
|
|
.insert(documentWatchers)
|
|
|
|
|
.values({ documentId, userId, addedBy: meta.userId })
|
|
|
|
|
.onConflictDoNothing()
|
|
|
|
|
.returning();
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'create',
|
|
|
|
|
entityType: 'document_watcher',
|
|
|
|
|
entityId: documentId,
|
|
|
|
|
newValue: { documentId, watcherUserId: userId },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
emitToRoom(`port:${portId}`, 'document:updated', { documentId });
|
|
|
|
|
return row ? { userId: row.userId, addedAt: row.addedAt } : { userId, addedAt: new Date() };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function removeDocumentWatcher(
|
|
|
|
|
documentId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
userId: string,
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
await getDocumentById(documentId, portId);
|
|
|
|
|
await db
|
|
|
|
|
.delete(documentWatchers)
|
|
|
|
|
.where(and(eq(documentWatchers.documentId, documentId), eq(documentWatchers.userId, userId)));
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'delete',
|
|
|
|
|
entityType: 'document_watcher',
|
|
|
|
|
entityId: documentId,
|
|
|
|
|
oldValue: { documentId, watcherUserId: userId },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
emitToRoom(`port:${portId}`, 'document:updated', { documentId });
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 02:12:05 +02:00
|
|
|
/**
|
2026-04-28 02:43:00 +02:00
|
|
|
* Create-document wizard entry point (PR6).
|
2026-04-28 02:12:05 +02:00
|
|
|
*
|
2026-04-28 02:43:00 +02:00
|
|
|
* Dispatches across pathways:
|
|
|
|
|
* - 'documenso-template' — Documenso renders + signs from its own template
|
|
|
|
|
* - 'inapp' — render PDF locally from a CRM template, upload to Documenso
|
|
|
|
|
* - 'upload' — admin-supplied PDF, upload to Documenso (auto-place signature
|
|
|
|
|
* fields if `autoPlaceFields`)
|
2026-04-28 02:12:05 +02:00
|
|
|
*
|
2026-04-28 02:43:00 +02:00
|
|
|
* Persists the document, applies reminder overrides, attaches watchers, and
|
|
|
|
|
* triggers send when `sendImmediately`.
|
2026-04-28 02:12:05 +02:00
|
|
|
*/
|
2026-04-28 02:43:00 +02:00
|
|
|
import type { CreateDocumentWizardInput } from '@/lib/validators/documents';
|
|
|
|
|
|
2026-04-28 02:12:05 +02:00
|
|
|
export async function createFromWizard(
|
2026-04-28 02:43:00 +02:00
|
|
|
portId: string,
|
|
|
|
|
data: CreateDocumentWizardInput,
|
|
|
|
|
meta: AuditMeta,
|
2026-04-28 02:12:05 +02:00
|
|
|
): Promise<typeof documents.$inferSelect> {
|
2026-04-28 02:43:00 +02:00
|
|
|
if (data.source === 'upload') {
|
|
|
|
|
return createFromUpload(portId, data, meta);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!data.templateId) {
|
|
|
|
|
throw new ValidationError('templateId is required for template source');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [doc] = await db
|
|
|
|
|
.insert(documents)
|
|
|
|
|
.values({
|
|
|
|
|
portId,
|
|
|
|
|
interestId: data.interestId ?? null,
|
|
|
|
|
reservationId: data.reservationId ?? null,
|
|
|
|
|
clientId: data.clientId ?? null,
|
|
|
|
|
companyId: data.companyId ?? null,
|
|
|
|
|
yachtId: data.yachtId ?? null,
|
|
|
|
|
documentType: data.documentType,
|
|
|
|
|
title: data.title,
|
|
|
|
|
notes: data.notes ?? null,
|
|
|
|
|
status: 'draft',
|
|
|
|
|
remindersDisabled: data.remindersDisabled,
|
|
|
|
|
reminderCadenceOverride: data.reminderCadenceOverride ?? null,
|
|
|
|
|
createdBy: meta.userId,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
if (!doc) throw new Error('Failed to insert document');
|
|
|
|
|
|
|
|
|
|
if (data.watchers.length > 0) {
|
|
|
|
|
await db.insert(documentWatchers).values(
|
|
|
|
|
data.watchers.map((userId) => ({
|
|
|
|
|
documentId: doc.id,
|
|
|
|
|
userId,
|
|
|
|
|
addedBy: meta.userId,
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'create',
|
|
|
|
|
entityType: 'document',
|
|
|
|
|
entityId: doc.id,
|
|
|
|
|
newValue: {
|
|
|
|
|
documentType: doc.documentType,
|
|
|
|
|
title: doc.title,
|
|
|
|
|
pathway: data.pathway,
|
|
|
|
|
source: data.source,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'document:created', { documentId: doc.id });
|
|
|
|
|
|
|
|
|
|
return doc;
|
2026-04-28 02:12:05 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-28 02:43:00 +02:00
|
|
|
* Upload-driven creation path. Files-service integration + Documenso upload
|
|
|
|
|
* + auto-place signature fields land alongside the realapi PR (PR11). For
|
|
|
|
|
* PR6 we persist the document row + signers + watchers and leave the
|
|
|
|
|
* Documenso upload step to the existing sendForSigning flow on first send.
|
2026-04-28 02:12:05 +02:00
|
|
|
*/
|
|
|
|
|
export async function createFromUpload(
|
2026-04-28 02:43:00 +02:00
|
|
|
portId: string,
|
|
|
|
|
data: CreateDocumentWizardInput,
|
|
|
|
|
meta: AuditMeta,
|
2026-04-28 02:12:05 +02:00
|
|
|
): Promise<typeof documents.$inferSelect> {
|
2026-04-28 02:43:00 +02:00
|
|
|
if (!data.uploadedFileId) {
|
|
|
|
|
throw new ValidationError('uploadedFileId is required for upload source');
|
|
|
|
|
}
|
|
|
|
|
if (!data.signers || data.signers.length === 0) {
|
|
|
|
|
throw new ValidationError('signers are required for upload source');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fileRecord = await db.query.files.findFirst({
|
|
|
|
|
where: and(eq(files.id, data.uploadedFileId), eq(files.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!fileRecord) {
|
|
|
|
|
throw new NotFoundError('File');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [doc] = await db
|
|
|
|
|
.insert(documents)
|
|
|
|
|
.values({
|
|
|
|
|
portId,
|
|
|
|
|
interestId: data.interestId ?? null,
|
|
|
|
|
reservationId: data.reservationId ?? null,
|
|
|
|
|
clientId: data.clientId ?? null,
|
|
|
|
|
companyId: data.companyId ?? null,
|
|
|
|
|
yachtId: data.yachtId ?? null,
|
|
|
|
|
documentType: data.documentType,
|
|
|
|
|
title: data.title,
|
|
|
|
|
notes: data.notes ?? null,
|
|
|
|
|
status: 'draft',
|
|
|
|
|
fileId: fileRecord.id,
|
|
|
|
|
remindersDisabled: data.remindersDisabled,
|
|
|
|
|
reminderCadenceOverride: data.reminderCadenceOverride ?? null,
|
|
|
|
|
createdBy: meta.userId,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
if (!doc) throw new Error('Failed to insert document');
|
|
|
|
|
|
|
|
|
|
await db.insert(documentSigners).values(
|
|
|
|
|
data.signers.map((s) => ({
|
|
|
|
|
documentId: doc.id,
|
|
|
|
|
signerName: s.signerName,
|
|
|
|
|
signerEmail: s.signerEmail,
|
|
|
|
|
signerRole: s.signerRole,
|
|
|
|
|
signingOrder: s.signingOrder,
|
|
|
|
|
status: 'pending' as const,
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (data.watchers.length > 0) {
|
|
|
|
|
await db.insert(documentWatchers).values(
|
|
|
|
|
data.watchers.map((userId) => ({
|
|
|
|
|
documentId: doc.id,
|
|
|
|
|
userId,
|
|
|
|
|
addedBy: meta.userId,
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'create',
|
|
|
|
|
entityType: 'document',
|
|
|
|
|
entityId: doc.id,
|
|
|
|
|
newValue: {
|
|
|
|
|
documentType: doc.documentType,
|
|
|
|
|
title: doc.title,
|
|
|
|
|
pathway: 'upload',
|
|
|
|
|
source: 'upload',
|
|
|
|
|
uploadedFileId: fileRecord.id,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'document:created', { documentId: doc.id });
|
|
|
|
|
|
|
|
|
|
return doc;
|
2026-04-28 02:12:05 +02:00
|
|
|
}
|