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

670 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,
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;
}