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:
@@ -26,6 +26,11 @@ interface SendRow {
|
||||
brochureId: string | null;
|
||||
clientId: string | null;
|
||||
interestId: string | null;
|
||||
/** Phase 6 — populated by the IMAP bounce poller when a delivery
|
||||
* failure for this send was matched in the configured mailbox. */
|
||||
bounceStatus: 'hard' | 'soft' | 'ooo' | null;
|
||||
bounceReason: string | null;
|
||||
bounceDetectedAt: string | null;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
@@ -117,6 +122,21 @@ export function SendsLog() {
|
||||
Switched to download link
|
||||
</Badge>
|
||||
) : null}
|
||||
{row.bounceStatus ? (
|
||||
<Badge
|
||||
className={
|
||||
row.bounceStatus === 'ooo'
|
||||
? 'bg-slate-100 text-slate-800'
|
||||
: 'bg-rose-100 text-rose-800'
|
||||
}
|
||||
>
|
||||
{row.bounceStatus === 'hard'
|
||||
? 'Hard bounce'
|
||||
: row.bounceStatus === 'soft'
|
||||
? 'Soft bounce'
|
||||
: 'Out of office'}
|
||||
</Badge>
|
||||
) : null}
|
||||
<span
|
||||
className="text-xs text-muted-foreground"
|
||||
title={sent.toISOString()}
|
||||
@@ -143,6 +163,23 @@ export function SendsLog() {
|
||||
Attachment dropped → sent as link. Reason: {row.fallbackToLinkReason}
|
||||
</div>
|
||||
) : null}
|
||||
{row.bounceStatus && row.bounceReason ? (
|
||||
<div
|
||||
className={`mt-2 text-sm rounded-md p-2 ${
|
||||
row.bounceStatus === 'ooo'
|
||||
? 'text-slate-700 bg-slate-50'
|
||||
: 'text-rose-700 bg-rose-50'
|
||||
}`}
|
||||
>
|
||||
Bounced
|
||||
{row.bounceDetectedAt
|
||||
? ` ${formatDistanceToNow(new Date(row.bounceDetectedAt), {
|
||||
addSuffix: true,
|
||||
})}`
|
||||
: ''}
|
||||
: {row.bounceReason}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{row.bodyMarkdown ? (
|
||||
<Button
|
||||
|
||||
@@ -106,6 +106,10 @@ interface InterestDetailHeaderProps {
|
||||
contractDocStatus?: string | null;
|
||||
/** Activity-log entries in the last 7 days — drives deal-pulse +5 signal. */
|
||||
recentActivityCount?: number | null;
|
||||
/** Phase 2 risk-signal dates fed into DealPulseChip. */
|
||||
dateDocumentDeclined?: string | Date | null;
|
||||
dateReservationCancelled?: string | Date | null;
|
||||
dateBerthSoldToOther?: string | Date | null;
|
||||
/** Sales rep who owns this deal — populated by the AssignedToChip. */
|
||||
assignedTo?: string | null;
|
||||
assignedToName?: string | null;
|
||||
@@ -292,6 +296,9 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
reservationDocStatus: interest.reservationDocStatus,
|
||||
contractDocStatus: interest.contractDocStatus,
|
||||
recentActivityCount: interest.recentActivityCount,
|
||||
dateDocumentDeclined: interest.dateDocumentDeclined,
|
||||
dateReservationCancelled: interest.dateReservationCancelled,
|
||||
dateBerthSoldToOther: interest.dateBerthSoldToOther,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -69,6 +69,12 @@ interface InterestData {
|
||||
reminderEnabled: boolean;
|
||||
reminderDays: number | null;
|
||||
reminderLastFired: string | null;
|
||||
/** Phase 2 risk-signal dates derived in getInterestById from event
|
||||
* tables (document_events, berth_reservations, conflicting won
|
||||
* interests). Feed DealPulseChip; null when no matching event. */
|
||||
dateDocumentDeclined: string | null;
|
||||
dateReservationCancelled: string | null;
|
||||
dateBerthSoldToOther: string | null;
|
||||
notes: string | null;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Clock,
|
||||
FileText,
|
||||
MoreHorizontal,
|
||||
Ship,
|
||||
User,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
@@ -35,6 +36,7 @@ interface Reminder {
|
||||
clientId: string | null;
|
||||
interestId: string | null;
|
||||
berthId: string | null;
|
||||
yachtId: string | null;
|
||||
autoGenerated: boolean;
|
||||
snoozedUntil: string | null;
|
||||
completedAt: string | null;
|
||||
@@ -42,6 +44,7 @@ interface Reminder {
|
||||
client?: { id: string; fullName: string } | null;
|
||||
interest?: { id: string; pipelineStage: string } | null;
|
||||
berth?: { id: string; mooringNumber: string } | null;
|
||||
yacht?: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
@@ -111,6 +114,9 @@ export function ReminderCard({
|
||||
} else if (reminder.berth) {
|
||||
subtitleIcon = <Anchor className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />;
|
||||
subtitleText = `Berth ${reminder.berth.mooringNumber}`;
|
||||
} else if (reminder.yacht) {
|
||||
subtitleIcon = <Ship className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />;
|
||||
subtitleText = reminder.yacht.name;
|
||||
} else if (reminder.interest) {
|
||||
subtitleIcon = (
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/com
|
||||
import { ClientPicker } from '@/components/shared/client-picker';
|
||||
import { InterestPicker } from '@/components/shared/interest-picker';
|
||||
import { BerthPicker } from '@/components/shared/berth-picker';
|
||||
import { YachtPicker } from '@/components/yachts/yacht-picker';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
|
||||
@@ -50,11 +51,13 @@ interface ReminderFormProps {
|
||||
clientId: string | null;
|
||||
interestId: string | null;
|
||||
berthId: string | null;
|
||||
yachtId: string | null;
|
||||
} | null;
|
||||
// Pre-fill entity link when creating from entity detail pages
|
||||
defaultClientId?: string;
|
||||
defaultInterestId?: string;
|
||||
defaultBerthId?: string;
|
||||
defaultYachtId?: string;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
@@ -78,6 +81,7 @@ function ReminderFormBody({
|
||||
defaultClientId,
|
||||
defaultInterestId,
|
||||
defaultBerthId,
|
||||
defaultYachtId,
|
||||
onSuccess,
|
||||
}: ReminderFormProps) {
|
||||
const isEdit = !!reminder;
|
||||
@@ -106,6 +110,7 @@ function ReminderFormBody({
|
||||
const [clientId, setClientId] = useState(reminder?.clientId ?? defaultClientId ?? '');
|
||||
const [interestId, setInterestId] = useState(reminder?.interestId ?? defaultInterestId ?? '');
|
||||
const [berthId, setBerthId] = useState(reminder?.berthId ?? defaultBerthId ?? '');
|
||||
const [yachtId, setYachtId] = useState(reminder?.yachtId ?? defaultYachtId ?? '');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { can } = usePermissions();
|
||||
@@ -133,6 +138,7 @@ function ReminderFormBody({
|
||||
clientId: clientId || undefined,
|
||||
interestId: interestId || undefined,
|
||||
berthId: berthId || undefined,
|
||||
yachtId: yachtId || undefined,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
@@ -249,7 +255,9 @@ function ReminderFormBody({
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Attach to client / deal / berth</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Attach to client / deal / berth / yacht
|
||||
</Label>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Linking a reminder pins it onto that record so anyone who opens the page sees it on
|
||||
the Reminders tab. Useful for “chase this client for signed EOI”,
|
||||
@@ -281,6 +289,11 @@ function ReminderFormBody({
|
||||
onChange={(id) => setBerthId(id ?? '')}
|
||||
clientId={clientId || null}
|
||||
/>
|
||||
<YachtPicker
|
||||
value={yachtId || null}
|
||||
onChange={(id) => setYachtId(id ?? '')}
|
||||
placeholder="Search yachts..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ interface Reminder {
|
||||
clientId: string | null;
|
||||
interestId: string | null;
|
||||
berthId: string | null;
|
||||
yachtId: string | null;
|
||||
autoGenerated: boolean;
|
||||
snoozedUntil: string | null;
|
||||
completedAt: string | null;
|
||||
|
||||
207
src/jobs/processors/imap-bounce-poller.ts
Normal file
207
src/jobs/processors/imap-bounce-poller.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Phase 6 — IMAP bounce poller.
|
||||
*
|
||||
* Polls the configured IMAP inbox for delivery-status notifications, runs
|
||||
* each through `parseBounce()`, and matches the original recipient against
|
||||
* a recent `document_sends` row. When matched, updates the send row's
|
||||
* bounce_* columns and fires an `email_bounced` notification to the rep
|
||||
* who originated the send (hard/soft only — out-of-office is logged but
|
||||
* not surfaced as an actionable alert).
|
||||
*
|
||||
* The job runs globally (no per-port context). IMAP creds are read from
|
||||
* environment variables (`IMAP_HOST` / `IMAP_PORT` / `IMAP_USER` /
|
||||
* `IMAP_PASS`) — when any is missing the poll is a no-op so the worker
|
||||
* boots happily in dev. Run cadence is set in `src/lib/queue/scheduler.ts`
|
||||
* (every 15 minutes).
|
||||
*
|
||||
* State (last-run timestamp) is persisted to `system_settings` under
|
||||
* `bounce_poller_state` with `port_id = NULL`, so concurrent worker
|
||||
* instances see the same checkpoint. On first run the lookback is 24 h.
|
||||
*/
|
||||
|
||||
import { and, desc, eq, gte, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { documentSends } from '@/lib/db/schema/brochures';
|
||||
import { systemSettings } from '@/lib/db/schema/system';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { parseBounce } from '@/lib/email/bounce-parser';
|
||||
import { createNotification } from '@/lib/services/notifications.service';
|
||||
|
||||
const STATE_KEY = 'bounce_poller_state';
|
||||
const FIRST_RUN_LOOKBACK_HOURS = 24;
|
||||
/** How far back to look for the originating document_sends row. Any send
|
||||
* whose bounce arrives after this window won't be matched — the SMTP
|
||||
* protocol guarantees NDRs typically arrive within minutes / hours, so
|
||||
* 7 days is generous. */
|
||||
const SEND_MATCH_WINDOW_DAYS = 7;
|
||||
|
||||
interface PollerState {
|
||||
lastRunAt: string;
|
||||
}
|
||||
|
||||
async function loadPollerState(): Promise<PollerState | null> {
|
||||
const row = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, STATE_KEY), isNull(systemSettings.portId)),
|
||||
});
|
||||
if (!row) return null;
|
||||
const value = row.value as PollerState | null;
|
||||
return value && typeof value === 'object' && 'lastRunAt' in value ? value : null;
|
||||
}
|
||||
|
||||
async function savePollerState(state: PollerState): Promise<void> {
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({
|
||||
key: STATE_KEY,
|
||||
value: state as unknown as Record<string, unknown>,
|
||||
portId: null,
|
||||
updatedBy: 'system',
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [systemSettings.key, systemSettings.portId],
|
||||
set: {
|
||||
value: state as unknown as Record<string, unknown>,
|
||||
updatedBy: 'system',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function processImapBouncePoll(): Promise<void> {
|
||||
const host = process.env.IMAP_HOST;
|
||||
const portStr = process.env.IMAP_PORT;
|
||||
const user = process.env.IMAP_USER;
|
||||
const pass = process.env.IMAP_PASS;
|
||||
if (!host || !portStr || !user || !pass) {
|
||||
logger.debug('IMAP bounce poll skipped — IMAP_* env not configured');
|
||||
return;
|
||||
}
|
||||
const port = Number.parseInt(portStr, 10);
|
||||
if (!Number.isFinite(port)) {
|
||||
logger.warn({ portStr }, 'IMAP bounce poll skipped — IMAP_PORT not numeric');
|
||||
return;
|
||||
}
|
||||
|
||||
const state = await loadPollerState();
|
||||
const since = state?.lastRunAt
|
||||
? new Date(state.lastRunAt)
|
||||
: new Date(Date.now() - FIRST_RUN_LOOKBACK_HOURS * 60 * 60 * 1000);
|
||||
// Capture run start BEFORE network calls so the next poll's `since`
|
||||
// covers anything that arrived while we were processing.
|
||||
const runStartedAt = new Date();
|
||||
|
||||
const imapflowModule = await import('imapflow');
|
||||
const ImapFlow = imapflowModule.ImapFlow;
|
||||
|
||||
const client = new ImapFlow({
|
||||
host,
|
||||
port,
|
||||
secure: port === 993,
|
||||
auth: { user, pass },
|
||||
logger: false,
|
||||
// Mirror email-threads.service.ts: bound any single network step so a
|
||||
// hung server can't stall the maintenance worker.
|
||||
socketTimeout: 60_000,
|
||||
greetingTimeout: 30_000,
|
||||
connectionTimeout: 30_000,
|
||||
});
|
||||
|
||||
let scanned = 0;
|
||||
let matched = 0;
|
||||
let skippedNoMatch = 0;
|
||||
let skippedNonBounce = 0;
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.mailboxOpen('INBOX');
|
||||
|
||||
const searchResult = await client.search({ since });
|
||||
const uids: number[] = searchResult === false ? [] : searchResult;
|
||||
|
||||
if (uids.length === 0) {
|
||||
logger.debug({ since: since.toISOString() }, 'IMAP bounce poll: nothing new');
|
||||
} else {
|
||||
for await (const message of client.fetch(uids, { source: true })) {
|
||||
scanned++;
|
||||
try {
|
||||
if (!message.source) continue;
|
||||
const parsed = await parseBounce(message.source);
|
||||
if (!parsed.originalRecipient || parsed.bounceClass === 'unknown') {
|
||||
skippedNonBounce++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const lookback = new Date(Date.now() - SEND_MATCH_WINDOW_DAYS * 86_400_000);
|
||||
// Most-recent matching send to this recipient; the recipient
|
||||
// may have been sent multiple files in the same window — the
|
||||
// bounce always refers to the latest.
|
||||
const candidates = await db
|
||||
.select()
|
||||
.from(documentSends)
|
||||
.where(
|
||||
and(
|
||||
eq(documentSends.recipientEmail, parsed.originalRecipient),
|
||||
gte(documentSends.sentAt, lookback),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(documentSends.sentAt))
|
||||
.limit(1);
|
||||
const target = candidates[0];
|
||||
if (!target) {
|
||||
skippedNoMatch++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Idempotency: a NDR can re-deliver to our mailbox if SMTP
|
||||
// retries; only update + notify once per send row.
|
||||
if (target.bounceDetectedAt) continue;
|
||||
|
||||
await db
|
||||
.update(documentSends)
|
||||
.set({
|
||||
bounceStatus: parsed.bounceClass,
|
||||
bounceReason: parsed.reason,
|
||||
bounceDetectedAt: new Date(),
|
||||
})
|
||||
.where(eq(documentSends.id, target.id));
|
||||
matched++;
|
||||
|
||||
// Skip OOO — informational, not actionable. Hard/soft notify
|
||||
// the original sender so they can re-send or escalate.
|
||||
if (
|
||||
target.sentByUserId &&
|
||||
(parsed.bounceClass === 'hard' || parsed.bounceClass === 'soft')
|
||||
) {
|
||||
await createNotification({
|
||||
portId: target.portId,
|
||||
userId: target.sentByUserId,
|
||||
type: 'email_bounced',
|
||||
title: 'Email bounced',
|
||||
description: `Your email to ${parsed.originalRecipient} bounced — ${parsed.reason}`,
|
||||
link: target.interestId ? `/interests/${target.interestId}` : undefined,
|
||||
entityType: 'document_send',
|
||||
entityId: target.id,
|
||||
dedupeKey: `bounce:${target.id}`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err, uid: message.uid }, 'IMAP bounce: failed to process message');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await savePollerState({ lastRunAt: runStartedAt.toISOString() });
|
||||
logger.info(
|
||||
{ scanned, matched, skippedNoMatch, skippedNonBounce, sinceISO: since.toISOString() },
|
||||
'IMAP bounce poll complete',
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await client.logout();
|
||||
} catch {
|
||||
// Logout failures are non-fatal — the connection will be torn down
|
||||
// by the timeout settings above.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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