Files
pn-new-crm/src/lib/db/schema/brochures.ts

166 lines
7.1 KiB
TypeScript
Raw Normal View History

fix(audit): post-review hardening across phases 0-7 15 of 17 findings from the consolidated audit (3 reviewer agents on the previously-shipped phase commits). Remaining two are nice-to-have follow-ups deferred. Critical (data integrity / security): - Public berths API: closed-deal junction rows no longer flip a berth to "Under Offer" - filter on `interests.outcome IS NULL` so won/ lost/cancelled don't pollute public-map status. Both list + single-mooring routes. - Recommender heat: cancelled outcomes now count as fall-throughs (SQL was `LIKE 'lost%'` which silently dropped them, leaving cancelled-only berths stuck in tier A). - Filesystem presignDownload returns an absolute URL (origin from APP_URL) so emailed download links resolve from external mail clients. - Magic-byte verification on the presigned-PUT path: both per-berth PDFs and brochures stream the first 5 bytes via the storage backend and reject + delete on `%PDF-` mismatch (was only enforced when the server saw the buffer; presign-PUT was wide open). - Replay-protection TTL aligned to the token's own expiry (was a fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling 25 days. - Brochures unique partial index on (port_id) WHERE is_default=true + 0032 migration. Closes the read-then-write race in the create/ update transactions. Important: - Recommender SQL: defense-in-depth `i.port_id = $portId` filter on the aggregates CTE. - berth-pdf service: per-berth pg_advisory_xact_lock around the version-number SELECT + insert. Storage key is now UUID-based so concurrent uploads can't collide on blob paths. Replaces `nextVersionNumber` with the tx-bound variant. - berth-pdf apply: rejects with ConflictError when parse_results contain a mooring-mismatch warning unless the caller passes `confirmMooringMismatch: true` (force-reconfirm gate was UI-only). - Send-out body: HTML-escape brochure filename in the download-link fallback (XSS guard). - parseDecimalWithUnit rejects negative numbers. - listClients DISTINCT ON for primary contact resolution: bounds contact-row count to ~2 per client. Defensive: - verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite. - Replaced sql ANY() with inArray() in interest-berths. Tests: 1145 -> 1163 passing. Deferred: bulk-send rate limit (no bulk endpoint today), markdown italic regex breaking links with asterisks (cosmetic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:07:03 +02:00
import {
pgTable,
text,
boolean,
integer,
timestamp,
index,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
import { ports } from './ports';
import { clients } from './clients';
import { interests } from './interests';
import { berths } from './berths';
fix(audit-v3): platform-wide deferred-list cleanup (rounds 1-4) Working through the audit-v2 deferred backlog. Each round was tested (typecheck + 1168/1168 vitest) before moving on. Round 1 — DB performance + AI cost visibility: - Add missing FK indexes Postgres doesn't auto-create on berth_reservations.{interest_id, contract_file_id}, documents.{file_id, signed_file_id}, document_events.signer_id, document_templates.source_file_id, form_submissions.{form_template_id, client_id}, document_sends.{brochure_id, brochure_version_id, sent_by_user_id}. Without these, RESTRICT-checks on parent delete + reverse-lookups walk the child tables fully. Migration 0037. - AI worker now writes one ai_usage_ledger row per OpenAI call so admins can audit spend per port/user/feature and future per-port budgets have history to read from. Failure to write is logged-not-thrown so the user-facing email draft is unaffected. Round 2 — Boot-time + transport hardening: - S3 backend verifies the bucket exists at startup (or auto-creates when MINIO_AUTO_CREATE_BUCKET=true). A typo'd bucket name now surfaces with a clear boot error instead of a vague Minio error inside the first user-facing request. - Documenso v1 placeFields: 3-attempt exponential-backoff retry on 5xx + network errors, fail-fast on 4xx. Stops one transient flake from leaving a document with a partial field set. - FilesystemBackend logs a structured warn-once at boot when the dev HMAC fallback is in effect, so two processes started with different BETTER_AUTH_SECRET values are observable (random 401s on file downloads otherwise). - Logger redact paths extended to cover *.headers.{authorization, cookie}, *.config.headers.authorization, encrypted-credential blobs (secretKeyEncrypted, smtpPassEncrypted, etc.), the Documenso X-Documenso-Secret header, and 2-level nested forms. Round 3 — UI feedback + permission gates: - Storage admin migrate dialog: success toast with row count + error toast on both dryRun and migrate mutations. - Invoice detail Send + Record-payment buttons wrapped in PermissionGate (invoices.send / invoices.record_payment); both mutations now toast on success/error. - Admin user list Edit button wrapped in PermissionGate(admin.manage_users). - Scan-receipt page surfaces an amber warning when OCR fails so reps know they can fill the form manually instead of staring at a stalled spinner; the editable form now also opens on scanMutation.isError / uploadedFile, not only on success. - Email threads list now renders skeleton rows during load + shared EmptyState for the empty case (was a single "Loading…" line). Round 4 — Service / route correctness: - documentSends.sent_by_user_id was a free-text NOT NULL column with no FK. Now nullable + FK to user(id) ON DELETE SET NULL so the audit row survives a user being hard-deleted. Migration 0038 with a defensive null-out for any orphan ids before attaching the constraint. - Saved-views route: documented why withAuth alone is correct (the service strictly filters by (portId, userId) — owner-only by design). - Public-interests audit log: replaced "userId: null as unknown as string" cast with userId: null; AuditLogParams already accepts null for system-generated events. - EOI in-app PDF fill: extracted setBerthRange() that, when the AcroForm field is missing AND the context has a non-empty range string, logs a structured warn so the deployment gap (live Documenso template needs the field) is observable instead of silently dropping the multi-berth range. Test status: 1168/1168 vitest. tsc clean. Two new migrations (0037/0038) need pnpm db:push (or migration apply) on the dev DB. Deferred-doc updated with the remaining open items (bigger refactors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:49:53 +02:00
import { user } from './users';
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
/**
* Port-wide brochures (Phase 7 see plan §3.3 / §4.8).
*
* Each port can have multiple brochures (e.g. "General", "Investor Pack")
* with one marked as `isDefault`. Archived brochures stay queryable for
* audit purposes but are hidden from the send-out picker.
*/
export const brochures = pgTable(
'brochures',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id, { onDelete: 'cascade' }),
label: text('label').notNull(),
description: text('description'),
isDefault: boolean('is_default').notNull().default(false),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
fix(audit): post-review hardening across phases 0-7 15 of 17 findings from the consolidated audit (3 reviewer agents on the previously-shipped phase commits). Remaining two are nice-to-have follow-ups deferred. Critical (data integrity / security): - Public berths API: closed-deal junction rows no longer flip a berth to "Under Offer" - filter on `interests.outcome IS NULL` so won/ lost/cancelled don't pollute public-map status. Both list + single-mooring routes. - Recommender heat: cancelled outcomes now count as fall-throughs (SQL was `LIKE 'lost%'` which silently dropped them, leaving cancelled-only berths stuck in tier A). - Filesystem presignDownload returns an absolute URL (origin from APP_URL) so emailed download links resolve from external mail clients. - Magic-byte verification on the presigned-PUT path: both per-berth PDFs and brochures stream the first 5 bytes via the storage backend and reject + delete on `%PDF-` mismatch (was only enforced when the server saw the buffer; presign-PUT was wide open). - Replay-protection TTL aligned to the token's own expiry (was a fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling 25 days. - Brochures unique partial index on (port_id) WHERE is_default=true + 0032 migration. Closes the read-then-write race in the create/ update transactions. Important: - Recommender SQL: defense-in-depth `i.port_id = $portId` filter on the aggregates CTE. - berth-pdf service: per-berth pg_advisory_xact_lock around the version-number SELECT + insert. Storage key is now UUID-based so concurrent uploads can't collide on blob paths. Replaces `nextVersionNumber` with the tx-bound variant. - berth-pdf apply: rejects with ConflictError when parse_results contain a mooring-mismatch warning unless the caller passes `confirmMooringMismatch: true` (force-reconfirm gate was UI-only). - Send-out body: HTML-escape brochure filename in the download-link fallback (XSS guard). - parseDecimalWithUnit rejects negative numbers. - listClients DISTINCT ON for primary contact resolution: bounds contact-row count to ~2 per client. Defensive: - verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite. - Replaced sql ANY() with inArray() in interest-berths. Tests: 1145 -> 1163 passing. Deferred: bulk-send rate limit (no bulk endpoint today), markdown italic regex breaking links with asterisks (cosmetic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:07:03 +02:00
(table) => [
index('idx_brochures_port').on(table.portId),
// At most one default brochure per port (excluding archived rows).
// Service-layer "demote prior default then insert" is correct in the
// single-writer case, but two concurrent createBrochure(isDefault:true)
// calls without this index race past the read-then-write check and
// both win.
uniqueIndex('idx_brochures_one_default_per_port')
.on(table.portId)
.where(sql`${table.isDefault} = true AND ${table.archivedAt} IS NULL`),
],
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
);
/**
* Versioned brochure files. Identical lifecycle to `berth_pdf_versions`:
* each upload creates a new immutable row with a monotonic version number
* per brochure. `storageKey` follows the §4.7a renamed convention.
*/
export const brochureVersions = pgTable(
'brochure_versions',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
brochureId: text('brochure_id')
.notNull()
.references(() => brochures.id, { onDelete: 'cascade' }),
versionNumber: integer('version_number').notNull(),
/** Object key in the active storage backend (renamed from `s3_key` per §4.7a). */
storageKey: text('storage_key').notNull(),
fileName: text('file_name').notNull(),
fileSizeBytes: integer('file_size_bytes').notNull(),
contentSha256: text('content_sha256').notNull(),
uploadedBy: text('uploaded_by').notNull(),
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow(),
/** Cached signed-URL expiry per §11.1 — re-sign only when within 1h of expiry. */
downloadUrlExpiresAt: timestamp('download_url_expires_at', { withTimezone: true }),
},
(table) => [index('idx_brochure_versions_brochure').on(table.brochureId, table.uploadedAt)],
);
/**
* Send-out audit log for berth PDFs and brochures (Phase 7 plan §3.3).
*
* One row per recipient per send. `documentKind` discriminates between
* `'berth_pdf'` and `'brochure'`; the corresponding `*_version_id` column
* pins the exact version sent.
*
* `berthPdfVersionId` is intentionally a plain text column (no FK) the
* referenced table `berth_pdf_versions` is owned by Phase 6b. Loose-coupling
* keeps the two phases independent (per Phase 7 task brief).
*
* `failedAt` and `errorReason` capture send failures (SMTP auth, transport
* errors). Failed sends are still written so reps can see "I clicked send
* but it didn't go" in the timeline (per §14.7).
*/
export const documentSends = pgTable(
'document_sends',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
/**
* Either client_id or interest_id is set (or both). All five FKs use
* `onDelete: 'set null'` so the audit row survives if the parent
* client/interest/berth/brochure is deleted `recipient_email`,
* `document_kind`, `body_markdown`, and `from_address` are denormalized
* onto the row precisely so the audit trail outlasts the source.
*/
clientId: text('client_id').references(() => clients.id, { onDelete: 'set null' }),
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
recipientEmail: text('recipient_email').notNull(),
/** 'berth_pdf' | 'brochure' */
documentKind: text('document_kind').notNull(),
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
berthId: text('berth_id').references(() => berths.id, { onDelete: 'set null' }),
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
/** Forward FK ref — berth_pdf_versions defined in Phase 6b. Loose-coupled. */
berthPdfVersionId: text('berth_pdf_version_id'),
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
brochureId: text('brochure_id').references(() => brochures.id, { onDelete: 'set null' }),
brochureVersionId: text('brochure_version_id').references(() => brochureVersions.id, {
onDelete: 'set null',
}),
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
/** Exact body used (after merge-field expansion + sanitization). */
bodyMarkdown: text('body_markdown'),
fix(audit-v3): platform-wide deferred-list cleanup (rounds 1-4) Working through the audit-v2 deferred backlog. Each round was tested (typecheck + 1168/1168 vitest) before moving on. Round 1 — DB performance + AI cost visibility: - Add missing FK indexes Postgres doesn't auto-create on berth_reservations.{interest_id, contract_file_id}, documents.{file_id, signed_file_id}, document_events.signer_id, document_templates.source_file_id, form_submissions.{form_template_id, client_id}, document_sends.{brochure_id, brochure_version_id, sent_by_user_id}. Without these, RESTRICT-checks on parent delete + reverse-lookups walk the child tables fully. Migration 0037. - AI worker now writes one ai_usage_ledger row per OpenAI call so admins can audit spend per port/user/feature and future per-port budgets have history to read from. Failure to write is logged-not-thrown so the user-facing email draft is unaffected. Round 2 — Boot-time + transport hardening: - S3 backend verifies the bucket exists at startup (or auto-creates when MINIO_AUTO_CREATE_BUCKET=true). A typo'd bucket name now surfaces with a clear boot error instead of a vague Minio error inside the first user-facing request. - Documenso v1 placeFields: 3-attempt exponential-backoff retry on 5xx + network errors, fail-fast on 4xx. Stops one transient flake from leaving a document with a partial field set. - FilesystemBackend logs a structured warn-once at boot when the dev HMAC fallback is in effect, so two processes started with different BETTER_AUTH_SECRET values are observable (random 401s on file downloads otherwise). - Logger redact paths extended to cover *.headers.{authorization, cookie}, *.config.headers.authorization, encrypted-credential blobs (secretKeyEncrypted, smtpPassEncrypted, etc.), the Documenso X-Documenso-Secret header, and 2-level nested forms. Round 3 — UI feedback + permission gates: - Storage admin migrate dialog: success toast with row count + error toast on both dryRun and migrate mutations. - Invoice detail Send + Record-payment buttons wrapped in PermissionGate (invoices.send / invoices.record_payment); both mutations now toast on success/error. - Admin user list Edit button wrapped in PermissionGate(admin.manage_users). - Scan-receipt page surfaces an amber warning when OCR fails so reps know they can fill the form manually instead of staring at a stalled spinner; the editable form now also opens on scanMutation.isError / uploadedFile, not only on success. - Email threads list now renders skeleton rows during load + shared EmptyState for the empty case (was a single "Loading…" line). Round 4 — Service / route correctness: - documentSends.sent_by_user_id was a free-text NOT NULL column with no FK. Now nullable + FK to user(id) ON DELETE SET NULL so the audit row survives a user being hard-deleted. Migration 0038 with a defensive null-out for any orphan ids before attaching the constraint. - Saved-views route: documented why withAuth alone is correct (the service strictly filters by (portId, userId) — owner-only by design). - Public-interests audit log: replaced "userId: null as unknown as string" cast with userId: null; AuditLogParams already accepts null for system-generated events. - EOI in-app PDF fill: extracted setBerthRange() that, when the AcroForm field is missing AND the context has a non-empty range string, logs a structured warn so the deployment gap (live Documenso template needs the field) is observable instead of silently dropping the multi-berth range. Test status: 1168/1168 vitest. tsc clean. Two new migrations (0037/0038) need pnpm db:push (or migration apply) on the dev DB. Deferred-doc updated with the remaining open items (bigger refactors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:49:53 +02:00
/**
* better-auth user id of the sender. SET NULL on user delete so the
* audit row keeps `recipientEmail` + timestamp + body for compliance
* even when the originating user is removed from the system.
*/
sentByUserId: text('sent_by_user_id').references(() => user.id, { onDelete: 'set null' }),
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
fromAddress: text('from_address').notNull(),
sentAt: timestamp('sent_at', { withTimezone: true }).notNull().defaultNow(),
/** SMTP provider message-id for deliverability tracking. */
messageId: text('message_id'),
/** When the initial send had its attachment dropped because the SMTP server
* rejected the size (552 etc.) and the system retried with a download
* link, this captures the rejection reason for ops visibility. Null when
* the original send went through as-is. */
fallbackToLinkReason: text('fallback_to_link_reason'),
/** Set when the SMTP send transaction itself failed (auth/transport/etc). */
failedAt: timestamp('failed_at', { withTimezone: true }),
/** Human-readable failure reason; only meaningful when failedAt is non-null. */
errorReason: text('error_reason'),
},
(t) => [
index('idx_ds_client').on(t.clientId, t.sentAt),
index('idx_ds_interest').on(t.interestId, t.sentAt),
index('idx_ds_berth').on(t.berthId, t.sentAt),
index('idx_ds_port').on(t.portId, t.sentAt),
fix(audit-v3): platform-wide deferred-list cleanup (rounds 1-4) Working through the audit-v2 deferred backlog. Each round was tested (typecheck + 1168/1168 vitest) before moving on. Round 1 — DB performance + AI cost visibility: - Add missing FK indexes Postgres doesn't auto-create on berth_reservations.{interest_id, contract_file_id}, documents.{file_id, signed_file_id}, document_events.signer_id, document_templates.source_file_id, form_submissions.{form_template_id, client_id}, document_sends.{brochure_id, brochure_version_id, sent_by_user_id}. Without these, RESTRICT-checks on parent delete + reverse-lookups walk the child tables fully. Migration 0037. - AI worker now writes one ai_usage_ledger row per OpenAI call so admins can audit spend per port/user/feature and future per-port budgets have history to read from. Failure to write is logged-not-thrown so the user-facing email draft is unaffected. Round 2 — Boot-time + transport hardening: - S3 backend verifies the bucket exists at startup (or auto-creates when MINIO_AUTO_CREATE_BUCKET=true). A typo'd bucket name now surfaces with a clear boot error instead of a vague Minio error inside the first user-facing request. - Documenso v1 placeFields: 3-attempt exponential-backoff retry on 5xx + network errors, fail-fast on 4xx. Stops one transient flake from leaving a document with a partial field set. - FilesystemBackend logs a structured warn-once at boot when the dev HMAC fallback is in effect, so two processes started with different BETTER_AUTH_SECRET values are observable (random 401s on file downloads otherwise). - Logger redact paths extended to cover *.headers.{authorization, cookie}, *.config.headers.authorization, encrypted-credential blobs (secretKeyEncrypted, smtpPassEncrypted, etc.), the Documenso X-Documenso-Secret header, and 2-level nested forms. Round 3 — UI feedback + permission gates: - Storage admin migrate dialog: success toast with row count + error toast on both dryRun and migrate mutations. - Invoice detail Send + Record-payment buttons wrapped in PermissionGate (invoices.send / invoices.record_payment); both mutations now toast on success/error. - Admin user list Edit button wrapped in PermissionGate(admin.manage_users). - Scan-receipt page surfaces an amber warning when OCR fails so reps know they can fill the form manually instead of staring at a stalled spinner; the editable form now also opens on scanMutation.isError / uploadedFile, not only on success. - Email threads list now renders skeleton rows during load + shared EmptyState for the empty case (was a single "Loading…" line). Round 4 — Service / route correctness: - documentSends.sent_by_user_id was a free-text NOT NULL column with no FK. Now nullable + FK to user(id) ON DELETE SET NULL so the audit row survives a user being hard-deleted. Migration 0038 with a defensive null-out for any orphan ids before attaching the constraint. - Saved-views route: documented why withAuth alone is correct (the service strictly filters by (portId, userId) — owner-only by design). - Public-interests audit log: replaced "userId: null as unknown as string" cast with userId: null; AuditLogParams already accepts null for system-generated events. - EOI in-app PDF fill: extracted setBerthRange() that, when the AcroForm field is missing AND the context has a non-empty range string, logs a structured warn so the deployment gap (live Documenso template needs the field) is observable instead of silently dropping the multi-berth range. Test status: 1168/1168 vitest. tsc clean. Two new migrations (0037/0038) need pnpm db:push (or migration apply) on the dev DB. Deferred-doc updated with the remaining open items (bigger refactors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:49:53 +02:00
// Reverse-lookups: "what sends used this brochure / version" and
// FK-RESTRICT scans on brochure delete.
index('idx_ds_brochure').on(t.brochureId),
index('idx_ds_brochure_version').on(t.brochureVersionId),
index('idx_ds_sent_by').on(t.sentByUserId),
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
],
);
export type Brochure = typeof brochures.$inferSelect;
export type NewBrochure = typeof brochures.$inferInsert;
export type BrochureVersion = typeof brochureVersions.$inferSelect;
export type NewBrochureVersion = typeof brochureVersions.$inferInsert;
export type DocumentSend = typeof documentSends.$inferSelect;
export type NewDocumentSend = typeof documentSends.$inferInsert;