From 93c6554c95075eb36731d9b27de7d9bf8b979c8d Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 3 Jun 2026 14:09:16 +0200 Subject: [PATCH] fix(ui+alerts+email): prod walkthrough batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - proxy/banner: interest-berth-status-banner used the interest_berths junction id for /api/v1/berths/{id}/active-interests (404 on every interest with a sold/under-offer berth). Add berthId to BerthRow and use it for both the active-interests query and the BerthOccupancyChip. - scroll-area: override Radix viewport `display:table` (`[&>div]:!block`) so content respects the viewport width โ€” fixes notification alert cards overflowing past the popover. No horizontal-scroll ScrollArea in the app. - alert-card: drop the raw `interest.stale` rule key from the footer (plaintext only; the title already conveys the alert). - alert-rules (interest.stale): add a createdAt >14d floor so a bulk import that backdates dateLastContact doesn't instantly flag every migrated interest as stale and flood the alert rail. 14-day clock starts no earlier than when the interest entered this system. - env: allow EMAIL_REDIRECT_TO in production behind an explicit ALLOW_PROD_EMAIL_REDIRECT=true opt-in (beta: route all outbound mail to the operator inbox; default still refuses the footgun). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/alerts/alert-card.tsx | 2 -- .../interest-berth-status-banner.tsx | 10 +++++++--- src/components/ui/scroll-area.tsx | 8 +++++++- src/lib/env.ts | 19 +++++++++++++++---- src/lib/services/alert-rules.ts | 6 ++++++ 5 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/components/alerts/alert-card.tsx b/src/components/alerts/alert-card.tsx index 770d5c15..a8e090df 100644 --- a/src/components/alerts/alert-card.tsx +++ b/src/components/alerts/alert-card.tsx @@ -59,8 +59,6 @@ export function AlertCard({ alert, readOnly = false }: AlertCardProps) { ) : null}
{fired} - ยท - {alert.ruleId}
{!readOnly ? ( diff --git a/src/components/interests/interest-berth-status-banner.tsx b/src/components/interests/interest-berth-status-banner.tsx index 16a0d269..05e1d297 100644 --- a/src/components/interests/interest-berth-status-banner.tsx +++ b/src/components/interests/interest-berth-status-banner.tsx @@ -8,7 +8,11 @@ import { apiFetch } from '@/lib/api/client'; import { BerthOccupancyChip } from '@/components/berths/berth-occupancy-chip'; interface BerthRow { + // `id` is the interest_berths JUNCTION row id; `berthId` is the actual + // berth. The /active-interests endpoint is keyed by berth id โ€” using `id` + // here 404s every conflict lookup. id: string; + berthId: string; mooringNumber: string; status: string; isPrimary: boolean; @@ -67,9 +71,9 @@ export function InterestBerthStatusBanner({ // needs the unfiltered list to decide whether to render at all. const competingQueries = useQueries({ queries: conflicts.map((b) => ({ - queryKey: ['berth', b.id, 'active-interests'] as const, + queryKey: ['berth', b.berthId, 'active-interests'] as const, queryFn: () => - apiFetch<{ data: CompetingInterest[] }>(`/api/v1/berths/${b.id}/active-interests`), + apiFetch<{ data: CompetingInterest[] }>(`/api/v1/berths/${b.berthId}/active-interests`), enabled: conflicts.length > 0, staleTime: 30_000, })), @@ -126,7 +130,7 @@ export function InterestBerthStatusBanner({
  • {berth.mooringNumber}: - + {/* `[&>div]:!block` overrides Radix's default `display:table` on the + viewport's content wrapper. With table sizing, flex children that use + truncate/line-clamp size to their intrinsic width and overflow the + viewport horizontally (e.g. notification alert cards spilling past the + popover). Block makes content respect the viewport width. Safe: no + ScrollArea in the app scrolls horizontally. */} + {children} diff --git a/src/lib/env.ts b/src/lib/env.ts index db6e4c94..7ed96c92 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -49,8 +49,18 @@ const envSchema = z SMTP_PASS: z.string().optional(), SMTP_FROM: z.string().optional(), // Dev/test safety net: when set, sendEmail redirects every outbound message - // to this address regardless of the requested recipient. Leave empty in prod. + // to this address regardless of the requested recipient. Leave empty in prod + // UNLESS deliberately enabling the beta redirect (see ALLOW_PROD_EMAIL_REDIRECT). EMAIL_REDIRECT_TO: z.string().email().optional(), + // Explicit opt-in to allow EMAIL_REDIRECT_TO in production. The prod guard + // below normally refuses to boot with a redirect set (footgun: silently + // funnels all customer mail to one inbox). During beta we WANT that โ€” every + // outbound message goes to the operator inbox so nothing reaches real + // clients by accident. Must be set together with EMAIL_REDIRECT_TO. + ALLOW_PROD_EMAIL_REDIRECT: z + .enum(['true', 'false']) + .default('false') + .transform((v) => v === 'true'), // Encryption EMAIL_CREDENTIAL_KEY: z @@ -118,13 +128,14 @@ const envSchema = z // funnels every customer email (invites, EOIs, portal magic links, // contracts) to a single inbox. The audit caught this had only a // `logger.debug` line as forensic trail. Refuse boot when both are - // simultaneously set in production. - if (env.NODE_ENV === 'production' && env.EMAIL_REDIRECT_TO) { + // simultaneously set in production - UNLESS ALLOW_PROD_EMAIL_REDIRECT=true + // is set as a deliberate opt-in (beta: route all mail to the operator). + if (env.NODE_ENV === 'production' && env.EMAIL_REDIRECT_TO && !env.ALLOW_PROD_EMAIL_REDIRECT) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['EMAIL_REDIRECT_TO'], message: - 'EMAIL_REDIRECT_TO must NOT be set in production - it silently rewrites every outbound email recipient. Unset it before deploying.', + 'EMAIL_REDIRECT_TO must NOT be set in production - it silently rewrites every outbound email recipient. Unset it, or set ALLOW_PROD_EMAIL_REDIRECT=true to deliberately enable it (e.g. beta).', }); } }); diff --git a/src/lib/services/alert-rules.ts b/src/lib/services/alert-rules.ts index 03ef4734..cd57b5ab 100644 --- a/src/lib/services/alert-rules.ts +++ b/src/lib/services/alert-rules.ts @@ -99,6 +99,12 @@ async function interestStale(portId: string): Promise { eq(interests.portId, portId), inArray(interests.pipelineStage, STALE_STAGES), isNull(interests.archivedAt), + // An interest can't be "stale for 14+ days" if it has only existed for + // less than 14 days. Without this floor, a bulk import (which backdates + // dateLastContact to the legacy value) instantly flags every migrated + // interest as stale and floods the alert rail. The 14-day clock starts + // no earlier than when the interest entered THIS system. + lt(interests.createdAt, daysAgo(14)), or( lt(interests.dateLastContact, daysAgo(14)), and(isNull(interests.dateLastContact), lt(interests.updatedAt, daysAgo(14))),