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;
|
||||
|
||||
Reference in New Issue
Block a user