Files
pn-new-crm/src/lib/services/reminders.service.ts

578 lines
19 KiB
TypeScript
Raw Normal View History

feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
import { and, eq, isNull, lte, gte, desc, asc, inArray, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { reminders, interests, clients } from '@/lib/db/schema';
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
import { berths } from '@/lib/db/schema/berths';
import { yachts } from '@/lib/db/schema/yachts';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
feat(reporting): money-math sweep — Step 1 PRE-DEPLOY-PLAN Single coherent commit completing § 1.1 (hot-path correctness) plus § 1.1.4.5 (multi-berth EOI mooring fix). Numbers users see are now self-consistent across dashboard / kanban / hot deals / PDF reports. ## Active-interest sweep (canonical predicate everywhere) Routed every "active interest" filter through `activeInterestsWhere` (commit b966d81 helper). The helper enforces port-scoping + archivedAt IS NULL + outcome IS NULL — strict definition, won is closed. Touched sites: - src/lib/services/reminders.service.ts:digestPort — no longer fires reminders for won/lost/cancelled deals - src/lib/services/berths.service.ts:getLatestInterestStageByBerth - src/lib/services/client-archive-dossier.service.ts (next-in-line others lookup) - src/lib/services/client-archive.service.ts (remaining-under-offer recount before flipping berth back to available) - src/lib/services/client-restore.service.ts (yacht-usage check) - src/lib/services/interests.service.ts:listInterestsForBoard + getInterestStageCounts + the "others on same berth" lookup — kanban / board now exclude terminal deals - src/lib/services/report-generators.ts: fetchPipelineData, fetchRevenueData stage breakdowns, top-N interests ## Pipeline-value currency conversion `getKpis()` now fetches the port's defaultCurrency from `ports` and converts each berth's `priceCurrency`→port-default via `currency.service`. Returns `pipelineValue` + `pipelineValueCurrency` instead of the lying `pipelineValueUsd`. Missing rates fall through to raw amount summing (so the tile still shows an approximate number) — behind a follow-up to surface a "rates incomplete" indicator. 3 consumers updated: KpiCards, PipelineValueTile, ActiveDealsTile. ## Occupancy = sold only Both the dashboard KPI tile and the revenue-report PDF occupancy data now count only `berth.status='sold'`. `under_offer` is a hold, not occupation. The analytics timeline switches from `berth_reservations`-derived to a cumulative-won-deals derivation via `interests.outcome='won' AND outcome_at::date <= day` — same source of truth, historical shape preserved. ## Revenue PDF two-card layout Added `totalForecast` + `pipelineWeights` to `RevenueData`. Summary section now renders both: - "Completed revenue (won)" — money in the bank - "Forecast revenue (pipeline-weighted)" — expected pipeline value Pipeline weights resolve from `system_settings.pipeline_weights` (per-port admin override) and fall back to STAGE_WEIGHTS defaults. PDF and dashboard forecast tiles reconcile. ## Multi-berth EOI mooring (4.5) Documenso `Berth Number` form field now carries the formatBerthRange output for BOTH single- and multi-berth EOIs. Single-berth output is byte-identical to the legacy primary-only path (`formatBerthRange(['A1']) === 'A1'`). Multi-berth EOIs now render the full range ("A1-A3, B5") in the existing field instead of being silently dropped against a nonexistent `Berth Range` field. Dropped: - `'Berth Range'` from the Documenso formValues payload + TS type - `setBerthRange()` helper from fill-eoi-form.ts (now redundant) - The "missing Berth Range AcroForm field" warning log Updated CLAUDE.md to reflect — no Documenso admin template change needed. ## Tests - Updated `documenso-payload.test.ts` — new fixture asserts formatBerthRange output flows into Berth Number; multi-berth case added. - Updated `analytics-service.test.ts:computeOccupancyTimeline` — fixture creates a won interest instead of a reservation. - Updated `alerts-engine.test.ts:interest.stale` — fixture stage switched from dead `'in_communication'` to canonical `'qualified'`. - Updated `report-templates.test.tsx:revenue` — fixture carries `totalForecast` + `pipelineWeights` to match new RevenueData. 1373/1373 vitest pass. tsc + eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:19:38 +02:00
import { activeInterestsWhere } from '@/lib/services/active-interest';
import { createNotification } from '@/lib/services/notifications.service';
import { logger } from '@/lib/logger';
import type {
CreateReminderInput,
UpdateReminderInput,
SnoozeReminderInput,
ReminderListQuery,
} from '@/lib/validators/reminders';
// ─── List ────────────────────────────────────────────────────────────────────
export async function listReminders(portId: string, query: ReminderListQuery) {
const conditions = [eq(reminders.portId, portId)];
if (query.status) conditions.push(eq(reminders.status, query.status));
if (query.priority) conditions.push(eq(reminders.priority, query.priority));
if (query.assignedTo) conditions.push(eq(reminders.assignedTo, query.assignedTo));
if (query.clientId) conditions.push(eq(reminders.clientId, query.clientId));
if (query.interestId) conditions.push(eq(reminders.interestId, query.interestId));
if (query.berthId) conditions.push(eq(reminders.berthId, query.berthId));
feat(post-audit): Phase 4 polish + Phase 2 wiring + Phase 6 cron + CLAUDE.md Three of the master plan's "suggested execution order" items shipped this session; Phase 3b (EOI dialog overrides) deferred — estimate exceeded the remaining session time. - Phase 4 polish: yachtId field on <ReminderForm> via the existing YachtPicker, Ship-icon subtitle on <ReminderCard>, listReminders filter by yachtId, getReminder joins the yacht relation. - Phase 2 risk-signal data wiring: getInterestById derives the 3 dates (dateDocumentDeclined / dateReservationCancelled / dateBerthSoldToOther) from document_events / berth_reservations / cross-interest interest_berths in parallel — chosen over new schema columns to keep the master plan's "no new tables" promise. Threaded through to DealPulseChip. - Phase 6 cron + UI: src/jobs/processors/imap-bounce-poller.ts polls the configured IMAP mailbox (IMAP_* env), matches NDRs to recent document_sends rows via recipient + 7-day window, idempotent via bounceDetectedAt, fires email_bounced notifications on hard/soft (skips OOO). State persisted to system_settings.bounce_poller_state. Wired into maintenance queue at */15 * * * *. Admin /admin/sends page surfaces the bounce badge + reason inline. - CLAUDE.md: trimmed 27KB → ~19.5KB (~28% smaller bytes). Prose-heavy Documenso webhook / v1-v2 routing / Document folders sections rewritten as scannable bullets. Added a new "Working in this repo — skills, MCPs, agents" section promoting brainstorming/TDD/debugging/frontend-design skills, Context7/Playwright/Serena MCPs, and the Explore/feature-dev agents. Documented Phase 2 derivation choice in the data-model section. Quality gates: 1374/1374 vitest pass, tsc --noEmit clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:38:37 +02:00
if (query.yachtId) conditions.push(eq(reminders.yachtId, query.yachtId));
if (query.dueBefore) conditions.push(lte(reminders.dueAt, new Date(query.dueBefore)));
if (query.dueAfter) conditions.push(gte(reminders.dueAt, new Date(query.dueAfter)));
if (query.search) {
conditions.push(sql`${reminders.title} ILIKE ${'%' + query.search + '%'}`);
}
const orderDir = query.order === 'asc' ? asc : desc;
const orderCol = query.sort === 'priority' ? reminders.priority : reminders.dueAt;
const offset = (query.page - 1) * query.limit;
const [data, countResult] = await Promise.all([
db
.select()
.from(reminders)
.where(and(...conditions))
.orderBy(orderDir(orderCol))
.limit(query.limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(reminders)
.where(and(...conditions)),
]);
return {
data,
pagination: {
page: query.page,
limit: query.limit,
total: Number(countResult[0]?.count ?? 0),
},
};
}
export async function getMyReminders(userId: string, portId: string) {
return db
.select()
.from(reminders)
.where(
and(
eq(reminders.portId, portId),
eq(reminders.assignedTo, userId),
inArray(reminders.status, ['pending', 'snoozed']),
),
)
.orderBy(asc(reminders.dueAt));
}
export async function getOverdueReminders(portId: string) {
return db
.select()
.from(reminders)
.where(
and(
eq(reminders.portId, portId),
inArray(reminders.status, ['pending', 'snoozed']),
lte(reminders.dueAt, new Date()),
),
)
.orderBy(asc(reminders.dueAt));
}
export async function getUpcomingReminders(portId: string, days: number = 14) {
const until = new Date();
until.setDate(until.getDate() + days);
return db
.select()
.from(reminders)
.where(
and(
eq(reminders.portId, portId),
inArray(reminders.status, ['pending', 'snoozed']),
lte(reminders.dueAt, until),
gte(reminders.dueAt, new Date()),
),
)
.orderBy(asc(reminders.dueAt));
}
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
/**
* Validate that the supplied subject FKs (clientId / interestId / berthId)
* all point at rows inside the caller's port. Without this guard, a
* reminder created with a foreign-port FK would later be hydrated with
* `with: { client, interest, berth }` joins (no port filter on the
* relation) - leaking the foreign-tenant rows back to the attacker.
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
*/
async function assertReminderFksInPort(
portId: string,
fks: {
clientId?: string | null;
interestId?: string | null;
berthId?: string | null;
yachtId?: string | null;
},
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
): Promise<void> {
const checks: Array<Promise<void>> = [];
if (fks.clientId) {
checks.push(
db.query.clients
.findFirst({ where: and(eq(clients.id, fks.clientId), eq(clients.portId, portId)) })
.then((row) => {
if (!row) throw new ValidationError('clientId not found in this port');
}),
);
}
if (fks.interestId) {
checks.push(
db.query.interests
.findFirst({
where: and(eq(interests.id, fks.interestId), eq(interests.portId, portId)),
})
.then((row) => {
if (!row) throw new ValidationError('interestId not found in this port');
}),
);
}
if (fks.berthId) {
checks.push(
db.query.berths
.findFirst({ where: and(eq(berths.id, fks.berthId), eq(berths.portId, portId)) })
.then((row) => {
if (!row) throw new ValidationError('berthId not found in this port');
}),
);
}
if (fks.yachtId) {
checks.push(
db.query.yachts
.findFirst({ where: and(eq(yachts.id, fks.yachtId), eq(yachts.portId, portId)) })
.then((row) => {
if (!row) throw new ValidationError('yachtId not found in this port');
}),
);
}
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
await Promise.all(checks);
}
// ─── CRUD ────────────────────────────────────────────────────────────────────
export async function getReminder(id: string, portId: string) {
const reminder = await db.query.reminders.findFirst({
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
feat(post-audit): Phase 4 polish + Phase 2 wiring + Phase 6 cron + CLAUDE.md Three of the master plan's "suggested execution order" items shipped this session; Phase 3b (EOI dialog overrides) deferred — estimate exceeded the remaining session time. - Phase 4 polish: yachtId field on <ReminderForm> via the existing YachtPicker, Ship-icon subtitle on <ReminderCard>, listReminders filter by yachtId, getReminder joins the yacht relation. - Phase 2 risk-signal data wiring: getInterestById derives the 3 dates (dateDocumentDeclined / dateReservationCancelled / dateBerthSoldToOther) from document_events / berth_reservations / cross-interest interest_berths in parallel — chosen over new schema columns to keep the master plan's "no new tables" promise. Threaded through to DealPulseChip. - Phase 6 cron + UI: src/jobs/processors/imap-bounce-poller.ts polls the configured IMAP mailbox (IMAP_* env), matches NDRs to recent document_sends rows via recipient + 7-day window, idempotent via bounceDetectedAt, fires email_bounced notifications on hard/soft (skips OOO). State persisted to system_settings.bounce_poller_state. Wired into maintenance queue at */15 * * * *. Admin /admin/sends page surfaces the bounce badge + reason inline. - CLAUDE.md: trimmed 27KB → ~19.5KB (~28% smaller bytes). Prose-heavy Documenso webhook / v1-v2 routing / Document folders sections rewritten as scannable bullets. Added a new "Working in this repo — skills, MCPs, agents" section promoting brainstorming/TDD/debugging/frontend-design skills, Context7/Playwright/Serena MCPs, and the Explore/feature-dev agents. Documented Phase 2 derivation choice in the data-model section. Quality gates: 1374/1374 vitest pass, tsc --noEmit clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:38:37 +02:00
with: { client: true, interest: true, berth: true, yacht: true },
});
if (!reminder) throw new NotFoundError('Reminder');
return reminder;
}
export async function createReminder(portId: string, data: CreateReminderInput, meta: AuditMeta) {
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
await assertReminderFksInPort(portId, {
clientId: data.clientId,
interestId: data.interestId,
berthId: data.berthId,
yachtId: data.yachtId,
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
});
const [reminder] = await db
.insert(reminders)
.values({
portId,
title: data.title,
note: data.note ?? null,
dueAt: new Date(data.dueAt),
priority: data.priority,
assignedTo: data.assignedTo ?? meta.userId,
createdBy: meta.userId,
clientId: data.clientId ?? null,
interestId: data.interestId ?? null,
berthId: data.berthId ?? null,
yachtId: data.yachtId ?? null,
})
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'reminder',
entityId: reminder!.id,
newValue: { title: reminder!.title, dueAt: reminder!.dueAt, priority: reminder!.priority },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'reminder:created', {
reminderId: reminder!.id,
title: reminder!.title,
dueAt: reminder!.dueAt.toISOString(),
assignedTo: reminder!.assignedTo ?? meta.userId,
});
if (reminder!.assignedTo) {
emitToRoom(`user:${reminder!.assignedTo}`, 'reminder:created', {
reminderId: reminder!.id,
title: reminder!.title,
dueAt: reminder!.dueAt.toISOString(),
assignedTo: reminder!.assignedTo,
});
}
return reminder!;
}
export async function updateReminder(
id: string,
portId: string,
data: UpdateReminderInput,
meta: AuditMeta,
) {
const existing = await db.query.reminders.findFirst({
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
});
if (!existing) throw new NotFoundError('Reminder');
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (data.title !== undefined) updates.title = data.title;
if (data.note !== undefined) updates.note = data.note;
if (data.dueAt !== undefined) updates.dueAt = new Date(data.dueAt);
if (data.priority !== undefined) updates.priority = data.priority;
if (data.assignedTo !== undefined) updates.assignedTo = data.assignedTo;
if (data.clientId !== undefined) updates.clientId = data.clientId;
if (data.interestId !== undefined) updates.interestId = data.interestId;
if (data.berthId !== undefined) updates.berthId = data.berthId;
if (data.yachtId !== undefined) updates.yachtId = data.yachtId;
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
// Re-validate any subject-FK changes against the caller's port.
await assertReminderFksInPort(portId, {
clientId: data.clientId,
interestId: data.interestId,
berthId: data.berthId,
yachtId: data.yachtId,
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
});
const [updated] = await db
.update(reminders)
.set(updates)
.where(and(eq(reminders.id, id), eq(reminders.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'reminder',
entityId: id,
oldValue: { title: existing.title, dueAt: existing.dueAt, priority: existing.priority },
newValue: { title: updated!.title, dueAt: updated!.dueAt, priority: updated!.priority },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'reminder:updated', {
reminderId: updated!.id,
changedFields: Object.keys(data),
});
return updated!;
}
export async function deleteReminder(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.reminders.findFirst({
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
});
if (!existing) throw new NotFoundError('Reminder');
await db.delete(reminders).where(and(eq(reminders.id, id), eq(reminders.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'delete',
entityType: 'reminder',
entityId: id,
oldValue: { title: existing.title },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}
// ─── Status Actions ──────────────────────────────────────────────────────────
export async function completeReminder(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.reminders.findFirst({
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
});
if (!existing) throw new NotFoundError('Reminder');
if (existing.status === 'completed') throw new ValidationError('Reminder already completed');
const [updated] = await db
.update(reminders)
.set({
status: 'completed',
completedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(reminders.id, id), eq(reminders.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'reminder',
entityId: id,
oldValue: { status: existing.status },
newValue: { status: 'completed' },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'reminder:completed', {
reminderId: updated!.id,
title: updated!.title,
completedBy: meta.userId,
});
return updated!;
}
export async function snoozeReminder(
id: string,
portId: string,
data: SnoozeReminderInput,
meta: AuditMeta,
) {
const existing = await db.query.reminders.findFirst({
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
});
if (!existing) throw new NotFoundError('Reminder');
const [updated] = await db
.update(reminders)
.set({
status: 'snoozed',
snoozedUntil: new Date(data.snoozeUntil),
updatedAt: new Date(),
})
.where(and(eq(reminders.id, id), eq(reminders.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'reminder',
entityId: id,
oldValue: { status: existing.status },
newValue: { status: 'snoozed', snoozedUntil: data.snoozeUntil },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'reminder:snoozed', {
reminderId: updated!.id,
snoozedUntil: data.snoozeUntil,
});
return updated!;
}
export async function dismissReminder(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.reminders.findFirst({
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
});
if (!existing) throw new NotFoundError('Reminder');
const [updated] = await db
.update(reminders)
.set({ status: 'dismissed', updatedAt: new Date() })
.where(and(eq(reminders.id, id), eq(reminders.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'reminder',
entityId: id,
oldValue: { status: existing.status },
newValue: { status: 'dismissed' },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return updated!;
}
// ─── Background Processors ──────────────────────────────────────────────────
/**
* Hourly check: creates auto-follow-up reminders for interests with
* reminderEnabled=true where no activity in reminderDays days (BR-060).
*/
export async function processFollowUpReminders() {
const ports = await db.query.ports.findMany({ where: eq(sql`true`, true) });
for (const port of ports) {
const enabledInterests = await db
.select({
id: interests.id,
clientId: interests.clientId,
reminderDays: interests.reminderDays,
reminderLastFired: interests.reminderLastFired,
updatedAt: interests.updatedAt,
})
.from(interests)
feat(reporting): money-math sweep — Step 1 PRE-DEPLOY-PLAN Single coherent commit completing § 1.1 (hot-path correctness) plus § 1.1.4.5 (multi-berth EOI mooring fix). Numbers users see are now self-consistent across dashboard / kanban / hot deals / PDF reports. ## Active-interest sweep (canonical predicate everywhere) Routed every "active interest" filter through `activeInterestsWhere` (commit b966d81 helper). The helper enforces port-scoping + archivedAt IS NULL + outcome IS NULL — strict definition, won is closed. Touched sites: - src/lib/services/reminders.service.ts:digestPort — no longer fires reminders for won/lost/cancelled deals - src/lib/services/berths.service.ts:getLatestInterestStageByBerth - src/lib/services/client-archive-dossier.service.ts (next-in-line others lookup) - src/lib/services/client-archive.service.ts (remaining-under-offer recount before flipping berth back to available) - src/lib/services/client-restore.service.ts (yacht-usage check) - src/lib/services/interests.service.ts:listInterestsForBoard + getInterestStageCounts + the "others on same berth" lookup — kanban / board now exclude terminal deals - src/lib/services/report-generators.ts: fetchPipelineData, fetchRevenueData stage breakdowns, top-N interests ## Pipeline-value currency conversion `getKpis()` now fetches the port's defaultCurrency from `ports` and converts each berth's `priceCurrency`→port-default via `currency.service`. Returns `pipelineValue` + `pipelineValueCurrency` instead of the lying `pipelineValueUsd`. Missing rates fall through to raw amount summing (so the tile still shows an approximate number) — behind a follow-up to surface a "rates incomplete" indicator. 3 consumers updated: KpiCards, PipelineValueTile, ActiveDealsTile. ## Occupancy = sold only Both the dashboard KPI tile and the revenue-report PDF occupancy data now count only `berth.status='sold'`. `under_offer` is a hold, not occupation. The analytics timeline switches from `berth_reservations`-derived to a cumulative-won-deals derivation via `interests.outcome='won' AND outcome_at::date <= day` — same source of truth, historical shape preserved. ## Revenue PDF two-card layout Added `totalForecast` + `pipelineWeights` to `RevenueData`. Summary section now renders both: - "Completed revenue (won)" — money in the bank - "Forecast revenue (pipeline-weighted)" — expected pipeline value Pipeline weights resolve from `system_settings.pipeline_weights` (per-port admin override) and fall back to STAGE_WEIGHTS defaults. PDF and dashboard forecast tiles reconcile. ## Multi-berth EOI mooring (4.5) Documenso `Berth Number` form field now carries the formatBerthRange output for BOTH single- and multi-berth EOIs. Single-berth output is byte-identical to the legacy primary-only path (`formatBerthRange(['A1']) === 'A1'`). Multi-berth EOIs now render the full range ("A1-A3, B5") in the existing field instead of being silently dropped against a nonexistent `Berth Range` field. Dropped: - `'Berth Range'` from the Documenso formValues payload + TS type - `setBerthRange()` helper from fill-eoi-form.ts (now redundant) - The "missing Berth Range AcroForm field" warning log Updated CLAUDE.md to reflect — no Documenso admin template change needed. ## Tests - Updated `documenso-payload.test.ts` — new fixture asserts formatBerthRange output flows into Berth Number; multi-berth case added. - Updated `analytics-service.test.ts:computeOccupancyTimeline` — fixture creates a won interest instead of a reservation. - Updated `alerts-engine.test.ts:interest.stale` — fixture stage switched from dead `'in_communication'` to canonical `'qualified'`. - Updated `report-templates.test.tsx:revenue` — fixture carries `totalForecast` + `pipelineWeights` to match new RevenueData. 1373/1373 vitest pass. tsc + eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:19:38 +02:00
.where(and(activeInterestsWhere(port.id), eq(interests.reminderEnabled, true)));
const now = new Date();
perf(audit-tier-3): bulk-fetch the five hot N+1 loops Replaces per-row fan-out with grouped queries / inArray pre-fetches across the five dashboard + cron hotspots flagged in the audit (MED §13 / HIGH §11–14): * reminders.processFollowUpReminders — was 3 round trips per enabled-and-due interest. Now: filter in JS, single clients bulk-fetch, single reminders bulk-insert, single interests bulk-update, one summary socket emit. 1k due interests: 6 round trips total instead of 3000+. * portal.getClientInvoices — was a full-table scan filtered in JS. Now an inArray push-down on lower(billingEmail) + defensive limit(100). After 12mo this would have been the worst portal endpoint. * interest-scoring.calculateBulkScores — was 6N round trips (1 redis + 1 findFirst + 4 counts per interest). Now 4 grouped count queries on the port's interest set + a single redis pipeline to refresh the cache. 1k interests: ~7 round trips. * document-reminders.processReminderQueue — was 5N round trips per cron tick (port + template + lastReminder + pendingSigners + send per doc). Now hoists port + per-type template map + grouped lastReminder + bulk pendingSigners; per-row work collapses to a Map.get and the documenso send. 500 docs: ~7 round trips. * inquiry-notifications.sendInquiryNotifications — was sequential createNotification + emailQueue.add per recipient inside a public POST. Now Promise.all'd; a 20-user port stops blocking the public inquiry POST on ~80 round trips. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§11–14 (auditor-I Issues 1–4) + MED §13 (auditor-I Issue 5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:41:23 +02:00
// Pick the interests whose follow-up window has elapsed. Pre-filtering
// here means the per-row N+1 walk that used to issue (1 client lookup
// + 1 reminder insert + 1 interest update) per interest is replaced by
// a single client-bulk-fetch + a single reminder bulk-insert + a
// single interests bulk-update against an `inArray` set.
const dueInterests = enabledInterests.filter((interest) => {
if (!interest.reminderDays) return false;
const lastActivity = interest.reminderLastFired ?? interest.updatedAt;
const daysSinceActivity = (now.getTime() - lastActivity.getTime()) / (1000 * 60 * 60 * 24);
perf(audit-tier-3): bulk-fetch the five hot N+1 loops Replaces per-row fan-out with grouped queries / inArray pre-fetches across the five dashboard + cron hotspots flagged in the audit (MED §13 / HIGH §11–14): * reminders.processFollowUpReminders — was 3 round trips per enabled-and-due interest. Now: filter in JS, single clients bulk-fetch, single reminders bulk-insert, single interests bulk-update, one summary socket emit. 1k due interests: 6 round trips total instead of 3000+. * portal.getClientInvoices — was a full-table scan filtered in JS. Now an inArray push-down on lower(billingEmail) + defensive limit(100). After 12mo this would have been the worst portal endpoint. * interest-scoring.calculateBulkScores — was 6N round trips (1 redis + 1 findFirst + 4 counts per interest). Now 4 grouped count queries on the port's interest set + a single redis pipeline to refresh the cache. 1k interests: ~7 round trips. * document-reminders.processReminderQueue — was 5N round trips per cron tick (port + template + lastReminder + pendingSigners + send per doc). Now hoists port + per-type template map + grouped lastReminder + bulk pendingSigners; per-row work collapses to a Map.get and the documenso send. 500 docs: ~7 round trips. * inquiry-notifications.sendInquiryNotifications — was sequential createNotification + emailQueue.add per recipient inside a public POST. Now Promise.all'd; a 20-user port stops blocking the public inquiry POST on ~80 round trips. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§11–14 (auditor-I Issues 1–4) + MED §13 (auditor-I Issue 5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:41:23 +02:00
return daysSinceActivity >= interest.reminderDays;
});
perf(audit-tier-3): bulk-fetch the five hot N+1 loops Replaces per-row fan-out with grouped queries / inArray pre-fetches across the five dashboard + cron hotspots flagged in the audit (MED §13 / HIGH §11–14): * reminders.processFollowUpReminders — was 3 round trips per enabled-and-due interest. Now: filter in JS, single clients bulk-fetch, single reminders bulk-insert, single interests bulk-update, one summary socket emit. 1k due interests: 6 round trips total instead of 3000+. * portal.getClientInvoices — was a full-table scan filtered in JS. Now an inArray push-down on lower(billingEmail) + defensive limit(100). After 12mo this would have been the worst portal endpoint. * interest-scoring.calculateBulkScores — was 6N round trips (1 redis + 1 findFirst + 4 counts per interest). Now 4 grouped count queries on the port's interest set + a single redis pipeline to refresh the cache. 1k interests: ~7 round trips. * document-reminders.processReminderQueue — was 5N round trips per cron tick (port + template + lastReminder + pendingSigners + send per doc). Now hoists port + per-type template map + grouped lastReminder + bulk pendingSigners; per-row work collapses to a Map.get and the documenso send. 500 docs: ~7 round trips. * inquiry-notifications.sendInquiryNotifications — was sequential createNotification + emailQueue.add per recipient inside a public POST. Now Promise.all'd; a 20-user port stops blocking the public inquiry POST on ~80 round trips. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§11–14 (auditor-I Issues 1–4) + MED §13 (auditor-I Issue 5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:41:23 +02:00
if (dueInterests.length === 0) continue;
perf(audit-tier-3): bulk-fetch the five hot N+1 loops Replaces per-row fan-out with grouped queries / inArray pre-fetches across the five dashboard + cron hotspots flagged in the audit (MED §13 / HIGH §11–14): * reminders.processFollowUpReminders — was 3 round trips per enabled-and-due interest. Now: filter in JS, single clients bulk-fetch, single reminders bulk-insert, single interests bulk-update, one summary socket emit. 1k due interests: 6 round trips total instead of 3000+. * portal.getClientInvoices — was a full-table scan filtered in JS. Now an inArray push-down on lower(billingEmail) + defensive limit(100). After 12mo this would have been the worst portal endpoint. * interest-scoring.calculateBulkScores — was 6N round trips (1 redis + 1 findFirst + 4 counts per interest). Now 4 grouped count queries on the port's interest set + a single redis pipeline to refresh the cache. 1k interests: ~7 round trips. * document-reminders.processReminderQueue — was 5N round trips per cron tick (port + template + lastReminder + pendingSigners + send per doc). Now hoists port + per-type template map + grouped lastReminder + bulk pendingSigners; per-row work collapses to a Map.get and the documenso send. 500 docs: ~7 round trips. * inquiry-notifications.sendInquiryNotifications — was sequential createNotification + emailQueue.add per recipient inside a public POST. Now Promise.all'd; a 20-user port stops blocking the public inquiry POST on ~80 round trips. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§11–14 (auditor-I Issues 1–4) + MED §13 (auditor-I Issue 5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:41:23 +02:00
const clientIds = Array.from(
new Set(dueInterests.map((i) => i.clientId).filter((v): v is string => Boolean(v))),
);
const clientsByIdEntries =
clientIds.length > 0
? await db
.select({ id: clients.id, fullName: clients.fullName })
.from(clients)
.where(inArray(clients.id, clientIds))
: [];
const clientById = new Map(clientsByIdEntries.map((c) => [c.id, c]));
const newReminders = dueInterests.map((interest) => {
const client = interest.clientId ? clientById.get(interest.clientId) : null;
return {
portId: port.id,
perf(audit-tier-3): bulk-fetch the five hot N+1 loops Replaces per-row fan-out with grouped queries / inArray pre-fetches across the five dashboard + cron hotspots flagged in the audit (MED §13 / HIGH §11–14): * reminders.processFollowUpReminders — was 3 round trips per enabled-and-due interest. Now: filter in JS, single clients bulk-fetch, single reminders bulk-insert, single interests bulk-update, one summary socket emit. 1k due interests: 6 round trips total instead of 3000+. * portal.getClientInvoices — was a full-table scan filtered in JS. Now an inArray push-down on lower(billingEmail) + defensive limit(100). After 12mo this would have been the worst portal endpoint. * interest-scoring.calculateBulkScores — was 6N round trips (1 redis + 1 findFirst + 4 counts per interest). Now 4 grouped count queries on the port's interest set + a single redis pipeline to refresh the cache. 1k interests: ~7 round trips. * document-reminders.processReminderQueue — was 5N round trips per cron tick (port + template + lastReminder + pendingSigners + send per doc). Now hoists port + per-type template map + grouped lastReminder + bulk pendingSigners; per-row work collapses to a Map.get and the documenso send. 500 docs: ~7 round trips. * inquiry-notifications.sendInquiryNotifications — was sequential createNotification + emailQueue.add per recipient inside a public POST. Now Promise.all'd; a 20-user port stops blocking the public inquiry POST on ~80 round trips. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§11–14 (auditor-I Issues 1–4) + MED §13 (auditor-I Issue 5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:41:23 +02:00
title: client ? `Follow up with ${client.fullName}` : 'Follow up on interest',
note: 'Auto-generated: no activity detected within the configured follow-up window.',
dueAt: now,
priority: 'medium',
assignedTo: null,
createdBy: 'system',
interestId: interest.id,
clientId: interest.clientId,
autoGenerated: true,
perf(audit-tier-3): bulk-fetch the five hot N+1 loops Replaces per-row fan-out with grouped queries / inArray pre-fetches across the five dashboard + cron hotspots flagged in the audit (MED §13 / HIGH §11–14): * reminders.processFollowUpReminders — was 3 round trips per enabled-and-due interest. Now: filter in JS, single clients bulk-fetch, single reminders bulk-insert, single interests bulk-update, one summary socket emit. 1k due interests: 6 round trips total instead of 3000+. * portal.getClientInvoices — was a full-table scan filtered in JS. Now an inArray push-down on lower(billingEmail) + defensive limit(100). After 12mo this would have been the worst portal endpoint. * interest-scoring.calculateBulkScores — was 6N round trips (1 redis + 1 findFirst + 4 counts per interest). Now 4 grouped count queries on the port's interest set + a single redis pipeline to refresh the cache. 1k interests: ~7 round trips. * document-reminders.processReminderQueue — was 5N round trips per cron tick (port + template + lastReminder + pendingSigners + send per doc). Now hoists port + per-type template map + grouped lastReminder + bulk pendingSigners; per-row work collapses to a Map.get and the documenso send. 500 docs: ~7 round trips. * inquiry-notifications.sendInquiryNotifications — was sequential createNotification + emailQueue.add per recipient inside a public POST. Now Promise.all'd; a 20-user port stops blocking the public inquiry POST on ~80 round trips. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§11–14 (auditor-I Issues 1–4) + MED §13 (auditor-I Issue 5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:41:23 +02:00
};
});
perf(audit-tier-3): bulk-fetch the five hot N+1 loops Replaces per-row fan-out with grouped queries / inArray pre-fetches across the five dashboard + cron hotspots flagged in the audit (MED §13 / HIGH §11–14): * reminders.processFollowUpReminders — was 3 round trips per enabled-and-due interest. Now: filter in JS, single clients bulk-fetch, single reminders bulk-insert, single interests bulk-update, one summary socket emit. 1k due interests: 6 round trips total instead of 3000+. * portal.getClientInvoices — was a full-table scan filtered in JS. Now an inArray push-down on lower(billingEmail) + defensive limit(100). After 12mo this would have been the worst portal endpoint. * interest-scoring.calculateBulkScores — was 6N round trips (1 redis + 1 findFirst + 4 counts per interest). Now 4 grouped count queries on the port's interest set + a single redis pipeline to refresh the cache. 1k interests: ~7 round trips. * document-reminders.processReminderQueue — was 5N round trips per cron tick (port + template + lastReminder + pendingSigners + send per doc). Now hoists port + per-type template map + grouped lastReminder + bulk pendingSigners; per-row work collapses to a Map.get and the documenso send. 500 docs: ~7 round trips. * inquiry-notifications.sendInquiryNotifications — was sequential createNotification + emailQueue.add per recipient inside a public POST. Now Promise.all'd; a 20-user port stops blocking the public inquiry POST on ~80 round trips. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§11–14 (auditor-I Issues 1–4) + MED §13 (auditor-I Issue 5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:41:23 +02:00
if (newReminders.length > 0) {
await db.insert(reminders).values(newReminders);
await db
.update(interests)
.set({ reminderLastFired: now })
perf(audit-tier-3): bulk-fetch the five hot N+1 loops Replaces per-row fan-out with grouped queries / inArray pre-fetches across the five dashboard + cron hotspots flagged in the audit (MED §13 / HIGH §11–14): * reminders.processFollowUpReminders — was 3 round trips per enabled-and-due interest. Now: filter in JS, single clients bulk-fetch, single reminders bulk-insert, single interests bulk-update, one summary socket emit. 1k due interests: 6 round trips total instead of 3000+. * portal.getClientInvoices — was a full-table scan filtered in JS. Now an inArray push-down on lower(billingEmail) + defensive limit(100). After 12mo this would have been the worst portal endpoint. * interest-scoring.calculateBulkScores — was 6N round trips (1 redis + 1 findFirst + 4 counts per interest). Now 4 grouped count queries on the port's interest set + a single redis pipeline to refresh the cache. 1k interests: ~7 round trips. * document-reminders.processReminderQueue — was 5N round trips per cron tick (port + template + lastReminder + pendingSigners + send per doc). Now hoists port + per-type template map + grouped lastReminder + bulk pendingSigners; per-row work collapses to a Map.get and the documenso send. 500 docs: ~7 round trips. * inquiry-notifications.sendInquiryNotifications — was sequential createNotification + emailQueue.add per recipient inside a public POST. Now Promise.all'd; a 20-user port stops blocking the public inquiry POST on ~80 round trips. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§11–14 (auditor-I Issues 1–4) + MED §13 (auditor-I Issue 5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:41:23 +02:00
.where(
inArray(
interests.id,
dueInterests.map((i) => i.id),
),
);
}
perf(audit-tier-3): bulk-fetch the five hot N+1 loops Replaces per-row fan-out with grouped queries / inArray pre-fetches across the five dashboard + cron hotspots flagged in the audit (MED §13 / HIGH §11–14): * reminders.processFollowUpReminders — was 3 round trips per enabled-and-due interest. Now: filter in JS, single clients bulk-fetch, single reminders bulk-insert, single interests bulk-update, one summary socket emit. 1k due interests: 6 round trips total instead of 3000+. * portal.getClientInvoices — was a full-table scan filtered in JS. Now an inArray push-down on lower(billingEmail) + defensive limit(100). After 12mo this would have been the worst portal endpoint. * interest-scoring.calculateBulkScores — was 6N round trips (1 redis + 1 findFirst + 4 counts per interest). Now 4 grouped count queries on the port's interest set + a single redis pipeline to refresh the cache. 1k interests: ~7 round trips. * document-reminders.processReminderQueue — was 5N round trips per cron tick (port + template + lastReminder + pendingSigners + send per doc). Now hoists port + per-type template map + grouped lastReminder + bulk pendingSigners; per-row work collapses to a Map.get and the documenso send. 500 docs: ~7 round trips. * inquiry-notifications.sendInquiryNotifications — was sequential createNotification + emailQueue.add per recipient inside a public POST. Now Promise.all'd; a 20-user port stops blocking the public inquiry POST on ~80 round trips. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§11–14 (auditor-I Issues 1–4) + MED §13 (auditor-I Issue 5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:41:23 +02:00
// Single port-room emit summarising the batch — the per-row emit was
// mostly noise to the dashboard and amplified socket traffic linearly
// with interest count.
if (newReminders.length > 0) {
emitToRoom(`port:${port.id}`, 'system:alert', {
alertType: 'follow_up_created',
perf(audit-tier-3): bulk-fetch the five hot N+1 loops Replaces per-row fan-out with grouped queries / inArray pre-fetches across the five dashboard + cron hotspots flagged in the audit (MED §13 / HIGH §11–14): * reminders.processFollowUpReminders — was 3 round trips per enabled-and-due interest. Now: filter in JS, single clients bulk-fetch, single reminders bulk-insert, single interests bulk-update, one summary socket emit. 1k due interests: 6 round trips total instead of 3000+. * portal.getClientInvoices — was a full-table scan filtered in JS. Now an inArray push-down on lower(billingEmail) + defensive limit(100). After 12mo this would have been the worst portal endpoint. * interest-scoring.calculateBulkScores — was 6N round trips (1 redis + 1 findFirst + 4 counts per interest). Now 4 grouped count queries on the port's interest set + a single redis pipeline to refresh the cache. 1k interests: ~7 round trips. * document-reminders.processReminderQueue — was 5N round trips per cron tick (port + template + lastReminder + pendingSigners + send per doc). Now hoists port + per-type template map + grouped lastReminder + bulk pendingSigners; per-row work collapses to a Map.get and the documenso send. 500 docs: ~7 round trips. * inquiry-notifications.sendInquiryNotifications — was sequential createNotification + emailQueue.add per recipient inside a public POST. Now Promise.all'd; a 20-user port stops blocking the public inquiry POST on ~80 round trips. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§11–14 (auditor-I Issues 1–4) + MED §13 (auditor-I Issue 5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:41:23 +02:00
message: `${newReminders.length} follow-up reminder${
newReminders.length === 1 ? '' : 's'
} created`,
severity: 'info',
});
}
perf(audit-tier-3): bulk-fetch the five hot N+1 loops Replaces per-row fan-out with grouped queries / inArray pre-fetches across the five dashboard + cron hotspots flagged in the audit (MED §13 / HIGH §11–14): * reminders.processFollowUpReminders — was 3 round trips per enabled-and-due interest. Now: filter in JS, single clients bulk-fetch, single reminders bulk-insert, single interests bulk-update, one summary socket emit. 1k due interests: 6 round trips total instead of 3000+. * portal.getClientInvoices — was a full-table scan filtered in JS. Now an inArray push-down on lower(billingEmail) + defensive limit(100). After 12mo this would have been the worst portal endpoint. * interest-scoring.calculateBulkScores — was 6N round trips (1 redis + 1 findFirst + 4 counts per interest). Now 4 grouped count queries on the port's interest set + a single redis pipeline to refresh the cache. 1k interests: ~7 round trips. * document-reminders.processReminderQueue — was 5N round trips per cron tick (port + template + lastReminder + pendingSigners + send per doc). Now hoists port + per-type template map + grouped lastReminder + bulk pendingSigners; per-row work collapses to a Map.get and the documenso send. 500 docs: ~7 round trips. * inquiry-notifications.sendInquiryNotifications — was sequential createNotification + emailQueue.add per recipient inside a public POST. Now Promise.all'd; a 20-user port stops blocking the public inquiry POST on ~80 round trips. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§11–14 (auditor-I Issues 1–4) + MED §13 (auditor-I Issue 5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:41:23 +02:00
logger.info(
{ portId: port.id, created: newReminders.length },
'Auto follow-up reminders created (bulk)',
);
}
}
/**
* Every 15 minutes: checks for past-due reminders and creates overdue notifications.
*/
export async function processOverdueReminders() {
const now = new Date();
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
// Un-snooze reminders whose snooze window has elapsed first, so a
// reminder that just transitioned snoozed → pending is eligible in
// the same tick (rather than waiting a full 15 minutes for the next
// scan).
await db
.update(reminders)
.set({ status: 'pending', snoozedUntil: null, updatedAt: now })
.where(and(eq(reminders.status, 'snoozed'), lte(reminders.snoozedUntil, now)));
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
// Phase 4 — claim due reminders by stamping fired_at in a single
// UPDATE...RETURNING. Postgres's row locks guarantee only one worker
// wins per row, so parallel maintenance workers can't double-fire the
// same reminder. Limited to status='pending' (the un-snooze pass
// above already promoted anything that was snoozed-expired).
//
// Partial index `idx_reminders_due_unfired` from migration 0072
// covers (port_id, due_at) WHERE fired_at IS NULL AND status IN
// ('pending', 'snoozed') so the scan stays cheap even on a large
// backlog of long-fired reminders.
const claimed = await db
.update(reminders)
.set({ firedAt: now, updatedAt: now })
.where(
and(eq(reminders.status, 'pending'), lte(reminders.dueAt, now), isNull(reminders.firedAt)),
)
.returning();
for (const reminder of claimed) {
if (!reminder.assignedTo) continue;
void createNotification({
portId: reminder.portId,
userId: reminder.assignedTo,
type: 'reminder_overdue',
title: 'Reminder overdue',
description: reminder.title,
entityType: 'reminder',
entityId: reminder.id,
link: '/reminders',
// Per-reminder dedup is now redundant given fired_at, but keep
// the key so a manual re-fire (e.g. ops clears fired_at) still
// respects the cooldown.
dedupeKey: `reminder:${reminder.id}`,
});
emitToRoom(`user:${reminder.assignedTo}`, 'reminder:overdue', {
reminderId: reminder.id,
title: reminder.title,
dueAt: reminder.dueAt.toISOString(),
});
}
logger.info({ firedCount: claimed.length }, 'Reminder cron: claimed + notified');
}