fix(ui+alerts+email): prod walkthrough batch
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -59,8 +59,6 @@ export function AlertCard({ alert, readOnly = false }: AlertCardProps) {
|
||||
) : null}
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{fired}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="font-mono text-xs">{alert.ruleId}</span>
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly ? (
|
||||
|
||||
@@ -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({
|
||||
<li key={berth.id} className="flex items-center gap-1.5 text-rose-900">
|
||||
<span className="font-medium shrink-0">{berth.mooringNumber}:</span>
|
||||
<BerthOccupancyChip
|
||||
berthId={berth.id}
|
||||
berthId={berth.berthId}
|
||||
portSlug={portSlug}
|
||||
excludeInterestId={interestId}
|
||||
compact
|
||||
|
||||
@@ -14,7 +14,13 @@ const ScrollArea = React.forwardRef<
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{/* `[&>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. */}
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
|
||||
@@ -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).',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -99,6 +99,12 @@ async function interestStale(portId: string): Promise<AlertCandidate[]> {
|
||||
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))),
|
||||
|
||||
Reference in New Issue
Block a user