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))),