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>
This commit is contained in:
@@ -54,6 +54,9 @@ export async function registerRecurringJobs(): Promise<void> {
|
||||
|
||||
// Phase B: alert rule engine sweep
|
||||
{ queue: 'maintenance', name: 'alerts-evaluate', pattern: '*/5 * * * *' },
|
||||
// Phase 6: IMAP bounce poller — matches NDRs to document_sends rows
|
||||
// and fires email_bounced notifications. No-op when IMAP_* env unset.
|
||||
{ queue: 'maintenance', name: 'bounce-poll', pattern: '*/15 * * * *' },
|
||||
// Phase B: analytics snapshot warm
|
||||
{ queue: 'maintenance', name: 'analytics-refresh', pattern: '*/15 * * * *' },
|
||||
|
||||
|
||||
@@ -55,6 +55,11 @@ export const maintenanceWorker = new Worker(
|
||||
logger.info(summary, 'Alert engine sweep complete');
|
||||
break;
|
||||
}
|
||||
case 'bounce-poll': {
|
||||
const { processImapBouncePoll } = await import('@/jobs/processors/imap-bounce-poller');
|
||||
await processImapBouncePoll();
|
||||
break;
|
||||
}
|
||||
case 'analytics-refresh': {
|
||||
const { ports } = await import('@/lib/db/schema/ports');
|
||||
const { refreshSnapshotsForPort } = await import('@/lib/services/analytics.service');
|
||||
|
||||
@@ -5,6 +5,8 @@ import { interests, interestBerths, interestTags, interestNotes } from '@/lib/db
|
||||
import { reminders, interestContactLog } from '@/lib/db/schema/operations';
|
||||
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
||||
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { companyMemberships } from '@/lib/db/schema/companies';
|
||||
import { tags } from '@/lib/db/schema/system';
|
||||
@@ -572,6 +574,69 @@ export async function getInterestById(id: string, portId: string) {
|
||||
and(eq(interestContactLog.interestId, id), gte(interestContactLog.occurredAt, sevenDaysAgo)),
|
||||
);
|
||||
|
||||
// Phase 2 — risk-signal derivation. Three dates feed `computeDealHealth`
|
||||
// off the existing event tables so the pulse chip surfaces document
|
||||
// declines / cancelled reservations / berth-resold-to-other without
|
||||
// adding bespoke timestamp columns on `interests`. Each query runs in
|
||||
// parallel; all return `null` when no matching event exists.
|
||||
const [declinedRow, cancelledReservationRow, berthResoldRow] = await Promise.all([
|
||||
// Latest 'rejected' / 'declined' document event whose document is
|
||||
// linked to this interest.
|
||||
db
|
||||
.select({ at: documentEvents.createdAt })
|
||||
.from(documentEvents)
|
||||
.innerJoin(documents, eq(documents.id, documentEvents.documentId))
|
||||
.where(
|
||||
and(
|
||||
eq(documents.interestId, id),
|
||||
inArray(documentEvents.eventType, ['rejected', 'declined']),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(documentEvents.createdAt))
|
||||
.limit(1),
|
||||
// Latest cancelled berth_reservation row pointing at this interest.
|
||||
// berth_reservations has no cancelled_at column; updatedAt is set when
|
||||
// the row flips to status='cancelled', so it tracks the same moment.
|
||||
db
|
||||
.select({ at: berthReservations.updatedAt })
|
||||
.from(berthReservations)
|
||||
.where(and(eq(berthReservations.interestId, id), eq(berthReservations.status, 'cancelled')))
|
||||
.orderBy(desc(berthReservations.updatedAt))
|
||||
.limit(1),
|
||||
// "Berth sold to another deal" — any of this interest's linked berths
|
||||
// has at least one OTHER interest with a `won` outcome. Take the
|
||||
// latest such outcome timestamp. archivedAt is a close proxy for the
|
||||
// moment the win was finalised on the conflicting deal.
|
||||
//
|
||||
// The inner subquery resolves *this* interest's berth_ids; the outer
|
||||
// query joins interestBerths to the won other-interest and filters
|
||||
// its berth_id against that set. Using raw `sql` avoids the alias
|
||||
// collision a Drizzle `exists()` would create with the same table on
|
||||
// both sides of the correlation.
|
||||
db.execute(
|
||||
sql`SELECT MAX(other.archived_at) AS at
|
||||
FROM interests other
|
||||
JOIN interest_berths ob ON ob.interest_id = other.id
|
||||
WHERE other.id <> ${id}
|
||||
AND other.outcome = 'won'
|
||||
AND ob.berth_id IN (
|
||||
SELECT berth_id FROM interest_berths WHERE interest_id = ${id}
|
||||
)`,
|
||||
),
|
||||
]);
|
||||
const dateDocumentDeclined = declinedRow[0]?.at ?? null;
|
||||
const dateReservationCancelled = cancelledReservationRow[0]?.at ?? null;
|
||||
// db.execute(sql`...`) returns either an array (postgres-js driver) or
|
||||
// a `{rows: []}` object depending on driver build — match the dual
|
||||
// shape used by src/lib/storage/migrate.ts.
|
||||
const berthResoldRaw = berthResoldRow as unknown as
|
||||
| Array<{ at: Date | null }>
|
||||
| { rows?: Array<{ at: Date | null }> };
|
||||
const berthResoldRows = Array.isArray(berthResoldRaw)
|
||||
? berthResoldRaw
|
||||
: (berthResoldRaw.rows ?? []);
|
||||
const dateBerthSoldToOther = berthResoldRows[0]?.at ?? null;
|
||||
|
||||
// Resolve the assignee's display name for the header chip — falling back
|
||||
// to the raw ID is fine if the user record is missing (deleted/disabled).
|
||||
let assignedToName: string | null = null;
|
||||
@@ -604,6 +669,13 @@ export async function getInterestById(id: string, portId: string) {
|
||||
activeReminderCount,
|
||||
assignedToName,
|
||||
recentActivityCount,
|
||||
// Phase 2 — risk-signal dates derived from event tables. Feed
|
||||
// computeDealHealth so the pulse chip surfaces document declines,
|
||||
// cancelled reservations, and "berth resold to another deal" without
|
||||
// bespoke timestamp columns on the interest record.
|
||||
dateDocumentDeclined,
|
||||
dateReservationCancelled,
|
||||
dateBerthSoldToOther,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export async function listReminders(portId: string, query: ReminderListQuery) {
|
||||
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));
|
||||
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)));
|
||||
|
||||
@@ -173,7 +174,7 @@ async function assertReminderFksInPort(
|
||||
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)),
|
||||
with: { client: true, interest: true, berth: true },
|
||||
with: { client: true, interest: true, berth: true, yacht: true },
|
||||
});
|
||||
if (!reminder) throw new NotFoundError('Reminder');
|
||||
return reminder;
|
||||
|
||||
Reference in New Issue
Block a user