Files
pn-new-crm/src/lib/services/files.ts

671 lines
22 KiB
TypeScript
Raw Normal View History

import { and, arrayContains, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { files, documents } from '@/lib/db/schema/documents';
import { expenses } from '@/lib/db/schema/financial';
import { berthMaintenanceLog } from '@/lib/db/schema/berths';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { getStorageBackend, presignDownloadUrl } from '@/lib/storage';
import { buildListQuery } from '@/lib/db/query-builder';
import { env } from '@/lib/env';
import {
ALLOWED_MIME_TYPES,
MAX_FILE_SIZE,
PREVIEWABLE_MIMES,
fix(audit-tier-6): validation, perms, ops/infra, per-port webhook secret Final audit polish — closes the remaining LOW + MED items the previous tiers didn't reach: * Validation hardening: me.preferences uses .strict() + 8KB cap instead of unbounded .passthrough(); files.uploadFile gains magic-byte verification (jpeg/png/gif/webp/pdf/doc/xlsx); OCR scan endpoint enforces 10MB cap + magic-byte check on receipt images; port logoUrl + me.avatarUrl reject javascript:/data: schemes via a shared httpUrl refinement. * Permission gates: document-sends/{brochure,berth-pdf} now require email.send (was withAuth-only); document-sends/{preview,list} on email.view; ai/email-draft on email.send; documents/[id]/send uses send_for_signing (was create); expenses/export/parent-company flips from hard isSuperAdmin to expenses.export for parity; admin/users/options gated on reminders.assign_others (was withAuth). * Envelope hygiene: auth/set-password switches the third {message} variant to errorResponse + {data: {email}}; ai/email-draft wraps jobId in {data: {jobId}}. * UI polish: reports-list.handleDownload surfaces failures via toastError (was console-only). * Ops/infra: pin pnpm@10.33.2 across all three Dockerfiles + packageManager field in package.json; Dockerfile.worker re-orders user creation BEFORE pnpm install so node_modules / .cache dirs are worker-owned (fixes tesseract.js + sharp EACCES at first PDF parse); add Redis-ping HEALTHCHECK to the worker container. * Public health endpoint: returns full env+appUrl payload only when the caller presents X-Intake-Secret, otherwise a minimal {status} so generic uptime monitors still work but anonymous internet doesn't get deployment fingerprints. * Per-port Documenso webhook secret: new system_settings key + listDocumensoWebhookSecrets() helper. The webhook receiver iterates every configured per-port secret with timing-safe comparison + falls back to env, then forwards the resolved portId into handleDocumentExpired so two ports sharing a documensoId cannot cross-mutate. Deferred (handled in dedicated follow-up PRs): * Tier 5.1 — direct service tests for portal-auth / users / email-accounts / document-sends / sales-email-config. MED, large test-writing scope. * The {ok: true} → {data: null} envelope migration across alerts/expenses/admin-ocr-settings/storage routes. Mechanical but needs coordinated client + test updates. * CSP-nonce migration (drop unsafe-inline) — needs middleware-level nonce generation that the Next 15 router has to thread through. * Idempotency-Key header on Documenso createDocument. Requires schema column on documents to persist the key; deferred so it doesn't bundle a migration into this commit. * The 16 better-auth user_id FKs — separate dedicated migration with care (some columns are NOT NULL today and cascade decisions matter). * PermissionGate / Skeleton / EmptyState wraps across 5 admin lists (auditor-H §§36–37) and the residential-clients filter bar. Test status: 1175/1175 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md MED §§28,29,30 + LOW §§32–43 + HIGH §9 (Documenso secrets follow-up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:03:31 +02:00
bufferMatchesMime,
} from '@/lib/constants/file-validation';
import { generateStorageKey, sanitizeFilename } from '@/lib/services/storage';
import type { UploadFileInput, UpdateFileInput, ListFilesInput } from '@/lib/validators/files';
import { documentFolders } from '@/lib/db/schema/documents';
import { clients } from '@/lib/db/schema/clients';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import type { EntityType } from '@/lib/services/document-folders.service';
// ─── Types ────────────────────────────────────────────────────────────────────
interface UploadFileParams {
buffer: Buffer;
originalName: string;
mimeType: string;
size: number;
}
// ─── Upload ───────────────────────────────────────────────────────────────────
export async function uploadFile(
portId: string,
portSlug: string,
file: UploadFileParams,
data: UploadFileInput,
meta: AuditMeta,
) {
if (!ALLOWED_MIME_TYPES.has(file.mimeType)) {
throw new ValidationError(`File type '${file.mimeType}' is not allowed`);
}
if (file.size > MAX_FILE_SIZE) {
throw new ValidationError('File exceeds maximum size of 50MB');
}
fix(audit-tier-6): validation, perms, ops/infra, per-port webhook secret Final audit polish — closes the remaining LOW + MED items the previous tiers didn't reach: * Validation hardening: me.preferences uses .strict() + 8KB cap instead of unbounded .passthrough(); files.uploadFile gains magic-byte verification (jpeg/png/gif/webp/pdf/doc/xlsx); OCR scan endpoint enforces 10MB cap + magic-byte check on receipt images; port logoUrl + me.avatarUrl reject javascript:/data: schemes via a shared httpUrl refinement. * Permission gates: document-sends/{brochure,berth-pdf} now require email.send (was withAuth-only); document-sends/{preview,list} on email.view; ai/email-draft on email.send; documents/[id]/send uses send_for_signing (was create); expenses/export/parent-company flips from hard isSuperAdmin to expenses.export for parity; admin/users/options gated on reminders.assign_others (was withAuth). * Envelope hygiene: auth/set-password switches the third {message} variant to errorResponse + {data: {email}}; ai/email-draft wraps jobId in {data: {jobId}}. * UI polish: reports-list.handleDownload surfaces failures via toastError (was console-only). * Ops/infra: pin pnpm@10.33.2 across all three Dockerfiles + packageManager field in package.json; Dockerfile.worker re-orders user creation BEFORE pnpm install so node_modules / .cache dirs are worker-owned (fixes tesseract.js + sharp EACCES at first PDF parse); add Redis-ping HEALTHCHECK to the worker container. * Public health endpoint: returns full env+appUrl payload only when the caller presents X-Intake-Secret, otherwise a minimal {status} so generic uptime monitors still work but anonymous internet doesn't get deployment fingerprints. * Per-port Documenso webhook secret: new system_settings key + listDocumensoWebhookSecrets() helper. The webhook receiver iterates every configured per-port secret with timing-safe comparison + falls back to env, then forwards the resolved portId into handleDocumentExpired so two ports sharing a documensoId cannot cross-mutate. Deferred (handled in dedicated follow-up PRs): * Tier 5.1 — direct service tests for portal-auth / users / email-accounts / document-sends / sales-email-config. MED, large test-writing scope. * The {ok: true} → {data: null} envelope migration across alerts/expenses/admin-ocr-settings/storage routes. Mechanical but needs coordinated client + test updates. * CSP-nonce migration (drop unsafe-inline) — needs middleware-level nonce generation that the Next 15 router has to thread through. * Idempotency-Key header on Documenso createDocument. Requires schema column on documents to persist the key; deferred so it doesn't bundle a migration into this commit. * The 16 better-auth user_id FKs — separate dedicated migration with care (some columns are NOT NULL today and cascade decisions matter). * PermissionGate / Skeleton / EmptyState wraps across 5 admin lists (auditor-H §§36–37) and the residential-clients filter bar. Test status: 1175/1175 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md MED §§28,29,30 + LOW §§32–43 + HIGH §9 (Documenso secrets follow-up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:03:31 +02:00
// Magic-byte verification — without this, the browser-declared MIME is
// attacker-controlled and a malicious uploader could ship arbitrary
// bytes through the ALLOWED_MIME_TYPES allow-list (auditor-E3 §27).
// Berth-PDF and brochure paths already do this; the generic uploader
// matches their guarantee here.
if (!bufferMatchesMime(file.buffer, file.mimeType)) {
throw new ValidationError(`File contents do not match the declared type '${file.mimeType}'`);
}
fix(audit-wave-11): asset hygiene + datetime correctness **asset-auditor C1+C2+H1+H3 — image normalization** Add `src/lib/services/image-normalize.ts` and wire it into `uploadFile()` so every accepted image is re-encoded via sharp before hitting storage: - Strips EXIF (GPS coords, device serial, photographer) so uploaded photos don't leak per-pixel PII to anyone with a download URL (C1). - Caps dimensions at 4096px via `resize({fit:'inside',withoutEnlargement:true})` so a 30000×30000 palette PNG can't decompression-bomb a downstream sharp decode (C2). - Re-encode drops polyglot trailers (PDF+JPEG sandwiches that beat the prefix-only magic-byte check) (H1). - Freezes animated GIFs to first frame (H3). Avatar route already funnels through uploadFile so it's covered by the single change. **asset-auditor M2 — sanitizeFilename strips RTL/zero-width** Add Unicode NFC + a strip of bidi-control (U+202A-U+202E, U+2066-U+2069) + zero-width chars (U+200B-U+200F, U+FEFF) to `sanitizeFilename`. Closes the classic Windows-icon-spoof vector (`invoice_‮fdp.exe` displaying as `invoice_exe.pdf`) plus folder-listing collision spoofs. **datetime-auditor C1 — reminder dueAt drift on every save** The `<input type="datetime-local">` round-trip in reminder-form.tsx used `iso.slice(0,16)` (load) and `new Date(value).toISOString()` (submit). The slice drops the `Z` so a UTC instant is mis-interpreted as local on load, then converted back to UTC on save — every save of an existing Warsaw reminder drifted backwards by 2h (CEST). After two saves the reminder appears at 06:00 instead of 10:00. Add `toLocalDatetimeLocal(d: Date)` helper that builds the local YYYY-MM-DDTHH:MM string from getter methods so the round-trip is TZ-safe. snooze-dialog already did this correctly; the contact-log dialog also uses the correct localIsoString pattern. **datetime-auditor C2 — BullMQ cron in UTC, not port-local** `upsertJobScheduler` defaulted `tz` to UTC. Patterns like `0 8 * * *` were intended as "8 AM Warsaw" but fired at 09:00 winter / 10:00 summer. Pass `tz: process.env.SCHEDULER_TZ ?? 'Europe/Warsaw'`. Sub-hourly / hourly patterns are TZ-invariant and stay UTC. **datetime-auditor C3 — report-scheduler never advanced next_run_at** The minutely scheduler selected `nextRunAt <= now()` and enqueued generate-report — but never bumped nextRunAt. For weekly/monthly reports this meant the job re-fired every single minute until a human zeroed the row out, flooding recipients with dupes. Now uses `cron-parser` (added as a dep) to compute the next fire from `report.schedule` and UPDATEs the row BEFORE the enqueue. Malformed cron expressions disable the row instead of re-attempting every minute. Tests 1315/1315. Migration 0058 applied via psql. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:58:58 +02:00
// Image normalisation (asset-auditor C1+C2+H1+H3): re-encode via
// sharp to strip EXIF (incl. GPS), drop polyglot trailing bytes, cap
// dimensions against decompression-bomb PNGs, and freeze animated
// GIFs to a single frame. Skips when sharp isn't available or the
// declared MIME isn't an image; failures fall back to the original
// buffer with a warning.
let normalizedBuffer = file.buffer;
let normalizedSize = file.size;
if (file.mimeType.startsWith('image/')) {
try {
const { normalizeImage } = await import('@/lib/services/image-normalize');
const normalized = await normalizeImage(file.buffer, file.mimeType);
normalizedBuffer = normalized.buffer;
normalizedSize = normalized.buffer.byteLength;
} catch (err) {
const { logger } = await import('@/lib/logger');
logger.warn(
{ err, mimeType: file.mimeType },
'image normalization failed; storing original (EXIF retained)',
);
}
}
const entity = data.entityType ?? 'general';
const entityId = data.entityId ?? portId;
const storagePath = generateStorageKey(portSlug, entity, entityId, file.mimeType);
const sanitizedOriginal = sanitizeFilename(file.originalName);
const sanitizedFilename = sanitizeFilename(data.filename);
const backend = await getStorageBackend();
fix(audit-wave-11): asset hygiene + datetime correctness **asset-auditor C1+C2+H1+H3 — image normalization** Add `src/lib/services/image-normalize.ts` and wire it into `uploadFile()` so every accepted image is re-encoded via sharp before hitting storage: - Strips EXIF (GPS coords, device serial, photographer) so uploaded photos don't leak per-pixel PII to anyone with a download URL (C1). - Caps dimensions at 4096px via `resize({fit:'inside',withoutEnlargement:true})` so a 30000×30000 palette PNG can't decompression-bomb a downstream sharp decode (C2). - Re-encode drops polyglot trailers (PDF+JPEG sandwiches that beat the prefix-only magic-byte check) (H1). - Freezes animated GIFs to first frame (H3). Avatar route already funnels through uploadFile so it's covered by the single change. **asset-auditor M2 — sanitizeFilename strips RTL/zero-width** Add Unicode NFC + a strip of bidi-control (U+202A-U+202E, U+2066-U+2069) + zero-width chars (U+200B-U+200F, U+FEFF) to `sanitizeFilename`. Closes the classic Windows-icon-spoof vector (`invoice_‮fdp.exe` displaying as `invoice_exe.pdf`) plus folder-listing collision spoofs. **datetime-auditor C1 — reminder dueAt drift on every save** The `<input type="datetime-local">` round-trip in reminder-form.tsx used `iso.slice(0,16)` (load) and `new Date(value).toISOString()` (submit). The slice drops the `Z` so a UTC instant is mis-interpreted as local on load, then converted back to UTC on save — every save of an existing Warsaw reminder drifted backwards by 2h (CEST). After two saves the reminder appears at 06:00 instead of 10:00. Add `toLocalDatetimeLocal(d: Date)` helper that builds the local YYYY-MM-DDTHH:MM string from getter methods so the round-trip is TZ-safe. snooze-dialog already did this correctly; the contact-log dialog also uses the correct localIsoString pattern. **datetime-auditor C2 — BullMQ cron in UTC, not port-local** `upsertJobScheduler` defaulted `tz` to UTC. Patterns like `0 8 * * *` were intended as "8 AM Warsaw" but fired at 09:00 winter / 10:00 summer. Pass `tz: process.env.SCHEDULER_TZ ?? 'Europe/Warsaw'`. Sub-hourly / hourly patterns are TZ-invariant and stay UTC. **datetime-auditor C3 — report-scheduler never advanced next_run_at** The minutely scheduler selected `nextRunAt <= now()` and enqueued generate-report — but never bumped nextRunAt. For weekly/monthly reports this meant the job re-fired every single minute until a human zeroed the row out, flooding recipients with dupes. Now uses `cron-parser` (added as a dep) to compute the next fire from `report.schedule` and UPDATEs the row BEFORE the enqueue. Malformed cron expressions disable the row instead of re-attempting every minute. Tests 1315/1315. Migration 0058 applied via psql. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:58:58 +02:00
await backend.put(storagePath, normalizedBuffer, {
contentType: file.mimeType,
fix(audit-wave-11): asset hygiene + datetime correctness **asset-auditor C1+C2+H1+H3 — image normalization** Add `src/lib/services/image-normalize.ts` and wire it into `uploadFile()` so every accepted image is re-encoded via sharp before hitting storage: - Strips EXIF (GPS coords, device serial, photographer) so uploaded photos don't leak per-pixel PII to anyone with a download URL (C1). - Caps dimensions at 4096px via `resize({fit:'inside',withoutEnlargement:true})` so a 30000×30000 palette PNG can't decompression-bomb a downstream sharp decode (C2). - Re-encode drops polyglot trailers (PDF+JPEG sandwiches that beat the prefix-only magic-byte check) (H1). - Freezes animated GIFs to first frame (H3). Avatar route already funnels through uploadFile so it's covered by the single change. **asset-auditor M2 — sanitizeFilename strips RTL/zero-width** Add Unicode NFC + a strip of bidi-control (U+202A-U+202E, U+2066-U+2069) + zero-width chars (U+200B-U+200F, U+FEFF) to `sanitizeFilename`. Closes the classic Windows-icon-spoof vector (`invoice_‮fdp.exe` displaying as `invoice_exe.pdf`) plus folder-listing collision spoofs. **datetime-auditor C1 — reminder dueAt drift on every save** The `<input type="datetime-local">` round-trip in reminder-form.tsx used `iso.slice(0,16)` (load) and `new Date(value).toISOString()` (submit). The slice drops the `Z` so a UTC instant is mis-interpreted as local on load, then converted back to UTC on save — every save of an existing Warsaw reminder drifted backwards by 2h (CEST). After two saves the reminder appears at 06:00 instead of 10:00. Add `toLocalDatetimeLocal(d: Date)` helper that builds the local YYYY-MM-DDTHH:MM string from getter methods so the round-trip is TZ-safe. snooze-dialog already did this correctly; the contact-log dialog also uses the correct localIsoString pattern. **datetime-auditor C2 — BullMQ cron in UTC, not port-local** `upsertJobScheduler` defaulted `tz` to UTC. Patterns like `0 8 * * *` were intended as "8 AM Warsaw" but fired at 09:00 winter / 10:00 summer. Pass `tz: process.env.SCHEDULER_TZ ?? 'Europe/Warsaw'`. Sub-hourly / hourly patterns are TZ-invariant and stay UTC. **datetime-auditor C3 — report-scheduler never advanced next_run_at** The minutely scheduler selected `nextRunAt <= now()` and enqueued generate-report — but never bumped nextRunAt. For weekly/monthly reports this meant the job re-fired every single minute until a human zeroed the row out, flooding recipients with dupes. Now uses `cron-parser` (added as a dep) to compute the next fire from `report.schedule` and UPDATEs the row BEFORE the enqueue. Malformed cron expressions disable the row instead of re-attempting every minute. Tests 1315/1315. Migration 0058 applied via psql. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:58:58 +02:00
sizeBytes: normalizedSize,
});
fix(uat-batch-1): wave-1 blocker bugs — supplemental gate, file FK, downloads, search dedup, notes stale, expense form, vocab Surgical fixes for the 7 UAT blockers that prevent productive forward testing. Each item has a corresponding entry in alpha-uat-master.md. - supplemental-info route relocated out of (portal) so it bypasses the isPortalDisabledGlobally() kill-switch. URL unchanged. - file upload service derives client_id/company_id/yacht_id from (entityType, entityId) when not explicitly passed, so interest-tab uploads no longer land with client_id=NULL and stay visible in the Attachments list. - triggerBlobDownload / triggerUrlDownload helpers in src/lib/utils attach the anchor to the DOM before click so Chromium honours the download attribute; 7 sites refactored, file-named downloads stop arriving as bare UUIDs. - search-nav-catalog dedupes by href at the result-collection layer so the same href can no longer surface twice in the command-K dropdown (kills the React duplicate-key warning); /admin/templates entries merged into a single richer-keyword variant. - NotesList gains a parentInvalidateKey prop, wired through all five callers (interest, client, yacht, company, residential client/ interest) so the Overview "Latest note" teaser refreshes when a note is added in the Notes tab. - expense-form-dialog: setValue('receiptFileIds') / setValue( 'noReceiptAcknowledged') on upload/clear/checkbox so the schema-level refine sees the field and Create stops silently no-op'ing on submit. - bulk-add-berths-wizard: side-pontoon dropdown now reads through useVocabulary('berth_side_pontoon_options') instead of a wrong local enum ('Port', 'Starboard', 'Bow', 'Stern') — wizard data now matches the rest of the platform + honours admin-editable per-port overrides. tsc clean. 1419/1419 vitest. lint clean on touched files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:50:58 +02:00
// Derive the entity FK from (entityType, entityId) when the caller
// didn't pass it explicitly. Without this, an interest-tab upload that
// sets `entityType='client'` + `entityId=<UUID>` lands with
// `client_id=NULL` — the Attachments list filters on `clientId` and
// the file vanishes from the interest's Documents tab.
const derivedClientId =
data.clientId ?? (data.entityType === 'client' ? (data.entityId ?? null) : null);
const derivedCompanyId =
data.companyId ?? (data.entityType === 'company' ? (data.entityId ?? null) : null);
const derivedYachtId =
data.yachtId ?? (data.entityType === 'yacht' ? (data.entityId ?? null) : null);
// E8: auto-set entity FK from system-managed folder when the rep uploads
// directly into a client/company/yacht folder. No-op for non-system folders.
const enrichedValues = await applyEntityFkFromFolder(portId, {
portId,
fix(uat-batch-1): wave-1 blocker bugs — supplemental gate, file FK, downloads, search dedup, notes stale, expense form, vocab Surgical fixes for the 7 UAT blockers that prevent productive forward testing. Each item has a corresponding entry in alpha-uat-master.md. - supplemental-info route relocated out of (portal) so it bypasses the isPortalDisabledGlobally() kill-switch. URL unchanged. - file upload service derives client_id/company_id/yacht_id from (entityType, entityId) when not explicitly passed, so interest-tab uploads no longer land with client_id=NULL and stay visible in the Attachments list. - triggerBlobDownload / triggerUrlDownload helpers in src/lib/utils attach the anchor to the DOM before click so Chromium honours the download attribute; 7 sites refactored, file-named downloads stop arriving as bare UUIDs. - search-nav-catalog dedupes by href at the result-collection layer so the same href can no longer surface twice in the command-K dropdown (kills the React duplicate-key warning); /admin/templates entries merged into a single richer-keyword variant. - NotesList gains a parentInvalidateKey prop, wired through all five callers (interest, client, yacht, company, residential client/ interest) so the Overview "Latest note" teaser refreshes when a note is added in the Notes tab. - expense-form-dialog: setValue('receiptFileIds') / setValue( 'noReceiptAcknowledged') on upload/clear/checkbox so the schema-level refine sees the field and Create stops silently no-op'ing on submit. - bulk-add-berths-wizard: side-pontoon dropdown now reads through useVocabulary('berth_side_pontoon_options') instead of a wrong local enum ('Port', 'Starboard', 'Bow', 'Stern') — wizard data now matches the rest of the platform + honours admin-editable per-port overrides. tsc clean. 1419/1419 vitest. lint clean on touched files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:50:58 +02:00
clientId: derivedClientId,
yachtId: derivedYachtId,
companyId: derivedCompanyId,
folderId: data.folderId ?? null,
filename: sanitizedFilename,
originalName: sanitizedOriginal,
mimeType: file.mimeType,
fix(audit-wave-11): asset hygiene + datetime correctness **asset-auditor C1+C2+H1+H3 — image normalization** Add `src/lib/services/image-normalize.ts` and wire it into `uploadFile()` so every accepted image is re-encoded via sharp before hitting storage: - Strips EXIF (GPS coords, device serial, photographer) so uploaded photos don't leak per-pixel PII to anyone with a download URL (C1). - Caps dimensions at 4096px via `resize({fit:'inside',withoutEnlargement:true})` so a 30000×30000 palette PNG can't decompression-bomb a downstream sharp decode (C2). - Re-encode drops polyglot trailers (PDF+JPEG sandwiches that beat the prefix-only magic-byte check) (H1). - Freezes animated GIFs to first frame (H3). Avatar route already funnels through uploadFile so it's covered by the single change. **asset-auditor M2 — sanitizeFilename strips RTL/zero-width** Add Unicode NFC + a strip of bidi-control (U+202A-U+202E, U+2066-U+2069) + zero-width chars (U+200B-U+200F, U+FEFF) to `sanitizeFilename`. Closes the classic Windows-icon-spoof vector (`invoice_‮fdp.exe` displaying as `invoice_exe.pdf`) plus folder-listing collision spoofs. **datetime-auditor C1 — reminder dueAt drift on every save** The `<input type="datetime-local">` round-trip in reminder-form.tsx used `iso.slice(0,16)` (load) and `new Date(value).toISOString()` (submit). The slice drops the `Z` so a UTC instant is mis-interpreted as local on load, then converted back to UTC on save — every save of an existing Warsaw reminder drifted backwards by 2h (CEST). After two saves the reminder appears at 06:00 instead of 10:00. Add `toLocalDatetimeLocal(d: Date)` helper that builds the local YYYY-MM-DDTHH:MM string from getter methods so the round-trip is TZ-safe. snooze-dialog already did this correctly; the contact-log dialog also uses the correct localIsoString pattern. **datetime-auditor C2 — BullMQ cron in UTC, not port-local** `upsertJobScheduler` defaulted `tz` to UTC. Patterns like `0 8 * * *` were intended as "8 AM Warsaw" but fired at 09:00 winter / 10:00 summer. Pass `tz: process.env.SCHEDULER_TZ ?? 'Europe/Warsaw'`. Sub-hourly / hourly patterns are TZ-invariant and stay UTC. **datetime-auditor C3 — report-scheduler never advanced next_run_at** The minutely scheduler selected `nextRunAt <= now()` and enqueued generate-report — but never bumped nextRunAt. For weekly/monthly reports this meant the job re-fired every single minute until a human zeroed the row out, flooding recipients with dupes. Now uses `cron-parser` (added as a dep) to compute the next fire from `report.schedule` and UPDATEs the row BEFORE the enqueue. Malformed cron expressions disable the row instead of re-attempting every minute. Tests 1315/1315. Migration 0058 applied via psql. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:58:58 +02:00
sizeBytes: String(normalizedSize),
storagePath,
storageBucket: env.MINIO_BUCKET,
category: data.category ?? null,
uploadedBy: meta.userId,
});
const [record] = await db.insert(files).values(enrichedValues).returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'file',
entityId: record!.id,
newValue: { filename: record!.filename, mimeType: file.mimeType, size: file.size },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'file:uploaded', {
fileId: record!.id,
filename: record!.filename,
});
return record!;
}
// ─── Download / Preview URLs ──────────────────────────────────────────────────
export async function getDownloadUrl(id: string, portId: string) {
const file = await getFileById(id, portId);
fix(uat-batch-2): external-EOI five-bug bundle (a/b/c/d) + presign filename override Tackles the linked B4 #5 findings on the external-EOI flow. Item (e) [Edit metadata affordance per row] is deferred to a later wave so it can share infra with the broader signing-flow rework. - (a) lying toast: uploadExternallySignedEoi now returns { stageChanged, newStage }. Client toasts conditionally so a Reservation+ deal that uploads paper-signing evidence no longer claims the stage advanced. - (b) View downloads instead of previewing: SignedPdfActions takes an onView callback; InterestEoiTab lifts a single FilePreviewDialog and passes the callback down. Click-View opens the in-app preview rather than the presigned URL (which the storage backend served as attachment). - (c) UUID filename on download: getDownloadUrl now passes the canonical filename through presignDownloadUrl; S3 backend adds a response-content-disposition override (filename + UTF-8 filename*) to the presign. Filesystem backend already passed it through. - (d) Discarded dateEoiSigned: external-eoi service splits document- metadata writes (always — dateEoiSigned, eoiStatus='signed') from stage advance (gated on past-EOI). Also fires evaluateRule('eoi_signed') so berth-rules stay in sync when an EOI is filed manually. - Default title for external-EOI dialog now derives "External EOI — <Client> — <berth range> — <date>" via the existing formatBerthRange helper; rep can override. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:01:35 +02:00
// Pass the canonical filename through to the presign so MinIO/S3
// returns Content-Disposition with the original name. Without the
// override the file lands with the bare storage-key UUID (no
// extension) in every browser.
const url = await presignDownloadUrl(file.storagePath, 900, file.filename);
return { url, filename: file.filename };
}
export async function getPreviewUrl(id: string, portId: string) {
const file = await getFileById(id, portId);
if (!file.mimeType || !PREVIEWABLE_MIMES.has(file.mimeType)) {
throw new ValidationError('This file type cannot be previewed');
}
const url = await presignDownloadUrl(file.storagePath);
return { url, mimeType: file.mimeType };
}
// ─── Update ───────────────────────────────────────────────────────────────────
export async function updateFile(
id: string,
portId: string,
data: UpdateFileInput,
meta: AuditMeta,
) {
const existing = await getFileById(id, portId);
const updates: { filename?: string; category?: string } = {};
if (data.filename !== undefined) updates.filename = sanitizeFilename(data.filename);
if (data.category !== undefined) updates.category = data.category;
const [updated] = await db
.update(files)
.set(updates)
.where(and(eq(files.id, id), eq(files.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'file',
entityId: id,
oldValue: { filename: existing.filename, category: existing.category },
newValue: updates,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'file:updated', { fileId: id });
return updated!;
}
// ─── Delete (BR-091) ──────────────────────────────────────────────────────────
export async function deleteFile(id: string, portId: string, meta: AuditMeta) {
const existing = await getFileById(id, portId);
// BR-091: check references before deleting
const [docRefs, expenseRefs, maintenanceRefs] = await Promise.all([
db
.select({ id: documents.id })
.from(documents)
.where(
and(
eq(documents.portId, portId),
or(eq(documents.fileId, id), eq(documents.signedFileId, id)),
),
)
.limit(1),
db
.select({ id: expenses.id })
.from(expenses)
.where(and(eq(expenses.portId, portId), arrayContains(expenses.receiptFileIds, [id])))
.limit(1),
db
.select({ id: berthMaintenanceLog.id })
.from(berthMaintenanceLog)
.where(
and(
eq(berthMaintenanceLog.portId, portId),
arrayContains(berthMaintenanceLog.photoFileIds, [id]),
),
)
.limit(1),
]);
if (docRefs.length > 0 || expenseRefs.length > 0 || maintenanceRefs.length > 0) {
throw new ConflictError('File cannot be deleted because it is referenced by other records');
}
// Delete the blob first, then DB. The storage backend's delete is
// idempotent, so a partial replay (worker crashed mid-delete) does not
// throw on the missing-object retry.
await (await getStorageBackend()).delete(existing.storagePath);
await db.delete(files).where(and(eq(files.id, id), eq(files.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'delete',
entityType: 'file',
entityId: id,
oldValue: { filename: existing.filename },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'file:deleted', { fileId: id });
}
// ─── List ─────────────────────────────────────────────────────────────────────
export async function listFiles(portId: string, query: ListFilesInput) {
fix(audit): non-Documenso backlog sweep — port-binding, NULLS NOT DISTINCT, custom merge tokens, company docs Wave through the remaining audit-final-deferred items that aren't blocked on the back-burnered Documenso work. Multi-tenant isolation: - Storage proxy ProxyTokenPayload gains optional `p` (port slug) claim; verifier asserts `key.startsWith(${p}/)`. Defense-in-depth against a buggy issuer in some future code path that mixes port scopes — every storage key generated by generateStorageKey() already prefixes the slug. document-sends opts in for 24h emailed download links; other callers continue working unchanged via the optional field. DB schema reconciliation: - Migration 0047 rebuilds system_settings unique index with NULLS NOT DISTINCT (Postgres 15+) so global settings (port_id IS NULL) are uniquely keyed by `key` alone. Surfaced + dedupe'd 65 duplicate (storage_backend, NULL) rows that had accumulated from race-prone delete-then-insert patterns in ocr-config / settings / residential- stages / ai-budget services. All four services converted to true onConflictDoUpdate upserts so the race window is closed. API uniformity: - Response shape standardization: 16 routes converted from `{ success: true }` to 204 No Content. CLAUDE.md documents the convention (`{ data: <T> }` for content, 204 for empty mutations, portal-auth retains `{ success: true }` for the frontend's auth chain). - req.json() → parseBody() migration across 9 admin/CRM routes (custom-fields, expenses/export ×3, currency convert, search/recently-viewed, admin/duplicates, berths/pdf-{upload-url, versions, parse-results}). Uniform 400 error shapes for ZodError-flagged bodies. Custom-fields merge tokens (shipped end-to-end): - merge-fields.ts gains CUSTOM_MERGE_TOKEN_RE + helpers for the `{{custom.<fieldName>}}` shape. - document-templates validator accepts the dynamic shape alongside the static catalog tokens. - document-sends.service mergeCustomFieldValues resolver fetches per-port custom_field_definitions for client/interest/berth contexts and substitutes stored values keyed by `{{custom.fieldName}}`. - custom-fields-manager amber banner updated to reflect that merge tokens now expand (search index + entity-diff remain documented design limitations). /api/v1/files cross-entity filtering: - Validator + listFiles + uploadFile accept companyId AND yachtId alongside clientId. file-upload-zone propagates both. - New CompanyFilesTab component mirrors ClientFilesTab; restored as a visible Documents tab in company-tabs.tsx (was a hidden stub). Inline TODOs: - Reviewed remaining two TODOs (per-user reminder schedule, import worker handlers). Both are placeholders for future feature surfaces, not bugs — per-port digest works for every customer; nothing currently enqueues import jobs (verified). Annotated in BACKLOG. BACKLOG.md updated to reflect what landed and what's still pending (Documenso-related items still bundled with the back-burnered phases). Tests: 1185/1185 vitest, tsc clean.
2026-05-08 02:20:27 +02:00
const { page, limit, sort, order, search, clientId, yachtId, companyId, category } = query;
const filters = [];
if (clientId) {
filters.push(eq(files.clientId, clientId));
}
fix(audit): non-Documenso backlog sweep — port-binding, NULLS NOT DISTINCT, custom merge tokens, company docs Wave through the remaining audit-final-deferred items that aren't blocked on the back-burnered Documenso work. Multi-tenant isolation: - Storage proxy ProxyTokenPayload gains optional `p` (port slug) claim; verifier asserts `key.startsWith(${p}/)`. Defense-in-depth against a buggy issuer in some future code path that mixes port scopes — every storage key generated by generateStorageKey() already prefixes the slug. document-sends opts in for 24h emailed download links; other callers continue working unchanged via the optional field. DB schema reconciliation: - Migration 0047 rebuilds system_settings unique index with NULLS NOT DISTINCT (Postgres 15+) so global settings (port_id IS NULL) are uniquely keyed by `key` alone. Surfaced + dedupe'd 65 duplicate (storage_backend, NULL) rows that had accumulated from race-prone delete-then-insert patterns in ocr-config / settings / residential- stages / ai-budget services. All four services converted to true onConflictDoUpdate upserts so the race window is closed. API uniformity: - Response shape standardization: 16 routes converted from `{ success: true }` to 204 No Content. CLAUDE.md documents the convention (`{ data: <T> }` for content, 204 for empty mutations, portal-auth retains `{ success: true }` for the frontend's auth chain). - req.json() → parseBody() migration across 9 admin/CRM routes (custom-fields, expenses/export ×3, currency convert, search/recently-viewed, admin/duplicates, berths/pdf-{upload-url, versions, parse-results}). Uniform 400 error shapes for ZodError-flagged bodies. Custom-fields merge tokens (shipped end-to-end): - merge-fields.ts gains CUSTOM_MERGE_TOKEN_RE + helpers for the `{{custom.<fieldName>}}` shape. - document-templates validator accepts the dynamic shape alongside the static catalog tokens. - document-sends.service mergeCustomFieldValues resolver fetches per-port custom_field_definitions for client/interest/berth contexts and substitutes stored values keyed by `{{custom.fieldName}}`. - custom-fields-manager amber banner updated to reflect that merge tokens now expand (search index + entity-diff remain documented design limitations). /api/v1/files cross-entity filtering: - Validator + listFiles + uploadFile accept companyId AND yachtId alongside clientId. file-upload-zone propagates both. - New CompanyFilesTab component mirrors ClientFilesTab; restored as a visible Documents tab in company-tabs.tsx (was a hidden stub). Inline TODOs: - Reviewed remaining two TODOs (per-user reminder schedule, import worker handlers). Both are placeholders for future feature surfaces, not bugs — per-port digest works for every customer; nothing currently enqueues import jobs (verified). Annotated in BACKLOG. BACKLOG.md updated to reflect what landed and what's still pending (Documenso-related items still bundled with the back-burnered phases). Tests: 1185/1185 vitest, tsc clean.
2026-05-08 02:20:27 +02:00
if (yachtId) {
filters.push(eq(files.yachtId, yachtId));
}
if (companyId) {
filters.push(eq(files.companyId, companyId));
}
if (category) {
filters.push(eq(files.category, category));
}
const sortColumn =
sort === 'filename' ? files.filename : sort === 'sizeBytes' ? files.sizeBytes : files.createdAt;
return buildListQuery({
table: files,
portIdColumn: files.portId,
portId,
idColumn: files.id,
updatedAtColumn: files.createdAt, // no updatedAt on files
searchColumns: [files.filename, files.originalName],
searchTerm: search,
filters,
sort: sort ? { column: sortColumn, direction: order } : undefined,
page,
pageSize: limit,
// no archivedAtColumn - files are immutable records
});
}
// ─── Get by ID ────────────────────────────────────────────────────────────────
export async function getFileById(id: string, portId: string) {
const file = await db.query.files.findFirst({
where: eq(files.id, id),
});
if (!file || file.portId !== portId) {
throw new NotFoundError('File');
}
return file;
}
// ─── Aggregated Projection ────────────────────────────────────────────────────
/**
* Row shape returned by the aggregated projection. Note this intentionally
* omits `storagePath` and `storageBucket` those are internal storage
* implementation details and must not leak out of the API to rep clients.
* Callers that need to download a file must use the documents/file
* download endpoint, which presigns from the bucket using the id, not the
* raw path.
*/
fix(audit-wave-11): asset hygiene + datetime correctness **asset-auditor C1+C2+H1+H3 — image normalization** Add `src/lib/services/image-normalize.ts` and wire it into `uploadFile()` so every accepted image is re-encoded via sharp before hitting storage: - Strips EXIF (GPS coords, device serial, photographer) so uploaded photos don't leak per-pixel PII to anyone with a download URL (C1). - Caps dimensions at 4096px via `resize({fit:'inside',withoutEnlargement:true})` so a 30000×30000 palette PNG can't decompression-bomb a downstream sharp decode (C2). - Re-encode drops polyglot trailers (PDF+JPEG sandwiches that beat the prefix-only magic-byte check) (H1). - Freezes animated GIFs to first frame (H3). Avatar route already funnels through uploadFile so it's covered by the single change. **asset-auditor M2 — sanitizeFilename strips RTL/zero-width** Add Unicode NFC + a strip of bidi-control (U+202A-U+202E, U+2066-U+2069) + zero-width chars (U+200B-U+200F, U+FEFF) to `sanitizeFilename`. Closes the classic Windows-icon-spoof vector (`invoice_‮fdp.exe` displaying as `invoice_exe.pdf`) plus folder-listing collision spoofs. **datetime-auditor C1 — reminder dueAt drift on every save** The `<input type="datetime-local">` round-trip in reminder-form.tsx used `iso.slice(0,16)` (load) and `new Date(value).toISOString()` (submit). The slice drops the `Z` so a UTC instant is mis-interpreted as local on load, then converted back to UTC on save — every save of an existing Warsaw reminder drifted backwards by 2h (CEST). After two saves the reminder appears at 06:00 instead of 10:00. Add `toLocalDatetimeLocal(d: Date)` helper that builds the local YYYY-MM-DDTHH:MM string from getter methods so the round-trip is TZ-safe. snooze-dialog already did this correctly; the contact-log dialog also uses the correct localIsoString pattern. **datetime-auditor C2 — BullMQ cron in UTC, not port-local** `upsertJobScheduler` defaulted `tz` to UTC. Patterns like `0 8 * * *` were intended as "8 AM Warsaw" but fired at 09:00 winter / 10:00 summer. Pass `tz: process.env.SCHEDULER_TZ ?? 'Europe/Warsaw'`. Sub-hourly / hourly patterns are TZ-invariant and stay UTC. **datetime-auditor C3 — report-scheduler never advanced next_run_at** The minutely scheduler selected `nextRunAt <= now()` and enqueued generate-report — but never bumped nextRunAt. For weekly/monthly reports this meant the job re-fired every single minute until a human zeroed the row out, flooding recipients with dupes. Now uses `cron-parser` (added as a dep) to compute the next fire from `report.schedule` and UPDATEs the row BEFORE the enqueue. Malformed cron expressions disable the row instead of re-attempting every minute. Tests 1315/1315. Migration 0058 applied via psql. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:58:58 +02:00
export type AggregatedFileRow = Omit<typeof files.$inferSelect, 'storagePath' | 'storageBucket'> & {
signedFromDocumentId: string | null;
};
export interface AggregatedFileGroup {
label: string;
source: 'direct' | 'client' | 'company' | 'yacht';
files: AggregatedFileRow[];
total: number;
}
interface AggregatedFilesResult {
groups: AggregatedFileGroup[];
}
const GROUP_LIMIT = 20;
/**
* Walk the relationship graph from the requested entity and return
* files grouped by source. Symmetric reach.
*
* Source of truth: each file's snapshotted entity FKs.
* Defense-in-depth: port_id at every entity / membership / yacht / file join.
*/
export async function listFilesAggregatedByEntity(
portId: string,
entityType: EntityType,
entityId: string,
): Promise<AggregatedFilesResult> {
const entityExists = await assertEntityInPort(portId, entityType, entityId);
if (!entityExists) return { groups: [] };
const related = await collectRelatedEntities(portId, entityType, entityId);
const groups: AggregatedFileGroup[] = [];
const directColumn =
entityType === 'client'
? files.clientId
: entityType === 'company'
? files.companyId
: files.yachtId;
const direct = await fetchGroupRows(portId, eq(directColumn, entityId), GROUP_LIMIT);
if (direct.rows.length > 0) {
groups.push({
label: 'DIRECTLY ATTACHED',
source: 'direct',
files: direct.rows,
total: direct.total,
});
}
for (const { id, name } of related.companies) {
const g = await fetchGroupRows(portId, eq(files.companyId, id), GROUP_LIMIT);
if (g.rows.length === 0) continue;
groups.push({
label: `FROM COMPANY: ${name.toUpperCase()}`,
source: 'company',
files: g.rows,
total: g.total,
});
}
for (const { id, name } of related.yachts) {
const g = await fetchGroupRows(portId, eq(files.yachtId, id), GROUP_LIMIT);
if (g.rows.length === 0) continue;
groups.push({
label: `FROM YACHT: ${name.toUpperCase()}`,
source: 'yacht',
files: g.rows,
total: g.total,
});
}
for (const { id, name } of related.clients) {
const g = await fetchGroupRows(portId, eq(files.clientId, id), GROUP_LIMIT);
if (g.rows.length === 0) continue;
groups.push({
label: `FROM CLIENT: ${name.toUpperCase()}`,
source: 'client',
files: g.rows,
total: g.total,
});
}
return { groups };
}
export async function assertEntityInPort(
portId: string,
entityType: EntityType,
entityId: string,
): Promise<boolean> {
if (entityType === 'client') {
const c = await db.query.clients.findFirst({
where: and(eq(clients.id, entityId), eq(clients.portId, portId)),
columns: { id: true },
});
return Boolean(c);
}
if (entityType === 'company') {
const c = await db.query.companies.findFirst({
where: and(eq(companies.id, entityId), eq(companies.portId, portId)),
columns: { id: true },
});
return Boolean(c);
}
const y = await db.query.yachts.findFirst({
where: and(eq(yachts.id, entityId), eq(yachts.portId, portId)),
columns: { id: true },
});
return Boolean(y);
}
export interface RelatedEntities {
clients: Array<{ id: string; name: string }>;
companies: Array<{ id: string; name: string }>;
yachts: Array<{ id: string; name: string }>;
}
/**
* Walk the relationship graph and collect related entity ids per
* source bucket. Symmetric reach. Every join carries port_id.
*
* Note: clients schema has fullName only (no firstName/lastName).
*/
export async function collectRelatedEntities(
portId: string,
entityType: EntityType,
entityId: string,
): Promise<RelatedEntities> {
if (entityType === 'client') {
const memberCompanies = await db
.select({ id: companies.id, name: companies.name })
.from(companyMemberships)
.innerJoin(
companies,
and(eq(companies.id, companyMemberships.companyId), eq(companies.portId, portId)),
)
.where(and(eq(companyMemberships.clientId, entityId), isNull(companyMemberships.endDate)));
const directYachts = await db
.select({ id: yachts.id, name: yachts.name })
.from(yachts)
.where(
and(
eq(yachts.portId, portId),
eq(yachts.currentOwnerType, 'client'),
eq(yachts.currentOwnerId, entityId),
),
);
let companyYachts: Array<{ id: string; name: string }> = [];
if (memberCompanies.length > 0) {
companyYachts = await db
.select({ id: yachts.id, name: yachts.name })
.from(yachts)
.where(
and(
eq(yachts.portId, portId),
eq(yachts.currentOwnerType, 'company'),
inArray(
yachts.currentOwnerId,
memberCompanies.map((c) => c.id),
),
),
);
}
return {
clients: [],
companies: memberCompanies,
yachts: dedupeBy([...directYachts, ...companyYachts], (y) => y.id),
};
}
if (entityType === 'company') {
// Adapted: use fullName not firstName/lastName.
const memberClients = await db
.select({ id: clients.id, fullName: clients.fullName })
.from(companyMemberships)
.innerJoin(
clients,
and(eq(clients.id, companyMemberships.clientId), eq(clients.portId, portId)),
)
.where(and(eq(companyMemberships.companyId, entityId), isNull(companyMemberships.endDate)));
const ownedYachts = await db
.select({ id: yachts.id, name: yachts.name })
.from(yachts)
.where(
and(
eq(yachts.portId, portId),
eq(yachts.currentOwnerType, 'company'),
eq(yachts.currentOwnerId, entityId),
),
);
return {
clients: memberClients.map((c) => ({ id: c.id, name: c.fullName })),
companies: [],
yachts: ownedYachts,
};
}
// yacht view
const yacht = await db.query.yachts.findFirst({
where: and(eq(yachts.id, entityId), eq(yachts.portId, portId)),
});
if (!yacht) return { clients: [], companies: [], yachts: [] };
if (yacht.currentOwnerType === 'client') {
const owner = await db.query.clients.findFirst({
where: and(eq(clients.id, yacht.currentOwnerId), eq(clients.portId, portId)),
columns: { id: true, fullName: true },
});
return {
clients: owner ? [{ id: owner.id, name: owner.fullName }] : [],
companies: [],
yachts: [],
};
}
const owner = await db.query.companies.findFirst({
where: and(eq(companies.id, yacht.currentOwnerId), eq(companies.portId, portId)),
columns: { id: true, name: true },
});
return {
clients: [],
companies: owner ? [{ id: owner.id, name: owner.name }] : [],
yachts: [],
};
}
async function fetchGroupRows(
portId: string,
predicate: ReturnType<typeof eq>,
limit: number,
): Promise<{
rows: AggregatedFileRow[];
total: number;
}> {
// A3: keep the LEFT JOIN's ON clause minimal so the planner can use the
// point-lookup index `idx_docs_signed_file_id` on the join, and apply the
// port_id residual in the WHERE (with the `OR d.id IS NULL` clause so the
// LEFT-JOIN semantics still preserve unjoined file rows). With port_id in
// the ON we used to fall back to `idx_docs_port` which is a wide-range scan.
const rows = await db
.select({
id: files.id,
portId: files.portId,
clientId: files.clientId,
yachtId: files.yachtId,
companyId: files.companyId,
feat(documents): foundation for nested interest subfolders (phase 1/3) Sets up the schema + service primitives the rest of the nested- document-subfolders feature will build on (master UAT line 728+). This commit is INFRASTRUCTURE ONLY — the upload-zone scope radio, lifecycle hooks for outcome rename, aggregated-projection list query, and backfill script are deferred to follow-up commits. Schema (migration 0078_files_interest_id.sql): - `files.interest_id` text REFERENCES interests(id) ON DELETE SET NULL. Mirrors the existing documents.interest_id; lets file uploads be scoped to a deal while still rolling up to the parent client folder. - idx_files_interest + idx_files_port_interest for the aggregated- projection queries that will surface "This deal" vs "From client" file lists. Service: - EntityType extended to include 'interest'. Interest folders parent under the owning client's entity folder (not at a system root), so the tree reads Clients/Acme/Deal A1-A3/ — nested. - ensureEntityFolder recursively ensures the parent client folder first when given an interest, guaranteeing the deal folder lands inside the right client subfolder even when the first artifact on the deal predates any client-level upload. - resolveEntityDisplayName for interest: "Deal — <mooringNumber>" (when a primary berth is linked) or "Deal <YYYY-MM-DD>" as the stable fallback. Dynamic-import on getPrimaryBerth dodges the circular dep between document-folders.service and interest-berths.service. Aggregated projection (files.ts): - listFilesAggregatedByEntity SELECT now includes the new interest_id column so AggregatedFileRow's structural type matches. Downstream consumers gain access to the deal scope; the actual "From this deal" subheading in InterestDocumentsTab is wired in the follow-up. Remaining work (tracked in master UAT line 728+, parked for next session): - UploadZone `scopeOptions` radio (single-option pickers hide the radio entirely for client/yacht/company surfaces). - Lifecycle hooks for interest outcome → folder rename ("Deal A1-A3 (Won)") via soft-rescue per CLAUDE.md. - listFilesAggregatedByEntity rewrite to surface "This deal" vs "From client" subheadings on InterestDocumentsTab. - Documents Hub tree rendering for nested interest folders. - backfill script: existing files with entity_type='interest' + entity_id but missing interest_id column → populate. Verified: tsc clean, vitest 1448/1448 after dev-DB migration applied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:18:40 +02:00
interestId: files.interestId,
folderId: files.folderId,
filename: files.filename,
originalName: files.originalName,
mimeType: files.mimeType,
sizeBytes: files.sizeBytes,
// storagePath + storageBucket intentionally omitted — see AggregatedFileRow doc.
category: files.category,
uploadedBy: files.uploadedBy,
createdAt: files.createdAt,
// Reverse-link: if any document row has this file as its signed_file_id,
// surface that document's id.
signedFromDocumentId: documents.id,
})
.from(files)
.leftJoin(documents, eq(documents.signedFileId, files.id))
.where(
and(
eq(files.portId, portId),
predicate,
// Defense-in-depth: keep the cross-port-leakage guard on the joined
// doc row but allow unjoined files (id IS NULL).
or(eq(documents.portId, portId), isNull(documents.id)),
),
)
.orderBy(desc(files.createdAt))
.limit(limit);
const [countRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(files)
.where(and(eq(files.portId, portId), predicate));
return { rows, total: Number(countRow?.count ?? 0) };
}
function dedupeBy<T, K>(items: T[], key: (t: T) => K): T[] {
const seen = new Set<K>();
const out: T[] = [];
for (const item of items) {
const k = key(item);
if (seen.has(k)) continue;
seen.add(k);
out.push(item);
}
return out;
}
// ─── E8: applyEntityFkFromFolder ─────────────────────────────────────────────
/**
* E8: when a rep manually uploads a file into a system-managed entity
* subfolder, auto-set the matching entity FK on the file row from the
* folder's entityType + entityId. Custom (non-system) folders
* returns the input unchanged.
*/
export async function applyEntityFkFromFolder<
T extends {
clientId?: string | null;
companyId?: string | null;
yachtId?: string | null;
folderId?: string | null;
},
>(portId: string, payload: T): Promise<T> {
if (!payload.folderId) return payload;
const folder = await db.query.documentFolders.findFirst({
where: and(eq(documentFolders.id, payload.folderId), eq(documentFolders.portId, portId)),
columns: { systemManaged: true, entityType: true, entityId: true },
});
if (!folder || !folder.systemManaged || !folder.entityType || !folder.entityId) {
return payload;
}
if (folder.entityType === 'client' && !payload.clientId) {
return { ...payload, clientId: folder.entityId };
}
if (folder.entityType === 'company' && !payload.companyId) {
return { ...payload, companyId: folder.entityId };
}
if (folder.entityType === 'yacht' && !payload.yachtId) {
return { ...payload, yachtId: folder.entityId };
}
return payload;
}