fix(b4-bugs): external-EOI cache collision + stage-gate regression test + search popover opacity
Three B4 bug fixes shipped together:
- **#4 Upload-signed-copy blank body** — ExternalEoiUploadDialog used
queryKey=['interests', interestId] but didn't unwrap the {data} envelope
while the parent InterestDetail (same key) does, so opening the dialog
clobbered the cache with a wrapped shape and blanked the detail page
("Unknown Client" + empty tab body). Dialog now unwraps to match.
- **#2 Legacy-stage canonicalization regression test** — new integration
test locks in the external-EOI advance gate: canonical pre-EOI stages
(enquiry/qualified/nurturing) advance to 'eoi' on upload; at-or-past-EOI
stages stay put while metadata still writes. 7/7 passing. Backfill
script intentionally not shipped — dev DB is test data, prod cutover
is manual.
- **#3 Global-search dropdown translucent rows** — defensive opaque
background on the popover wrapper (bg-white dark:bg-popover) guards
against the subtle transparency UAT captured on the Berths page.
Live-browser repro still needed to identify the exact bleeding row;
this defense makes the surface unambiguously solid in light mode
regardless of which class wins tailwind-merge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -81,14 +81,21 @@ export function ExternalEoiUploadDialog({
|
|||||||
// unscannable in any list when a port has multiple deals closing on
|
// unscannable in any list when a port has multiple deals closing on
|
||||||
// the same day. Also drives auto-fill on signatory rows tagged
|
// the same day. Also drives auto-fill on signatory rows tagged
|
||||||
// role=client.
|
// role=client.
|
||||||
|
// Cache shape MUST match the parent <InterestDetail> consumer of the same
|
||||||
|
// ['interests', interestId] key, which unwraps the envelope and caches the
|
||||||
|
// inner row. Caching the wrapped { data: ... } here would clobber the
|
||||||
|
// parent's cache and blank the detail page (the rep would see "Unknown
|
||||||
|
// Client" + an empty tab body the moment this dialog opened — UAT
|
||||||
|
// 2026-05-24).
|
||||||
const { data: interestData } = useQuery<{
|
const { data: interestData } = useQuery<{
|
||||||
data: { clientName: string | null; clientPrimaryEmail: string | null };
|
clientName: string | null;
|
||||||
|
clientPrimaryEmail: string | null;
|
||||||
}>({
|
}>({
|
||||||
queryKey: ['interests', interestId],
|
queryKey: ['interests', interestId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<{ data: { clientName: string | null; clientPrimaryEmail: string | null } }>(
|
apiFetch<{ data: { clientName: string | null; clientPrimaryEmail: string | null } }>(
|
||||||
`/api/v1/interests/${interestId}`,
|
`/api/v1/interests/${interestId}`,
|
||||||
),
|
).then((r) => r.data),
|
||||||
enabled: open,
|
enabled: open,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
@@ -99,11 +106,11 @@ export function ExternalEoiUploadDialog({
|
|||||||
const signatories: SignatoryRow[] = useMemo(() => {
|
const signatories: SignatoryRow[] = useMemo(() => {
|
||||||
if (signatoriesOverride !== null) return signatoriesOverride;
|
if (signatoriesOverride !== null) return signatoriesOverride;
|
||||||
if (prefillSignatories && prefillSignatories.length > 0) return prefillSignatories;
|
if (prefillSignatories && prefillSignatories.length > 0) return prefillSignatories;
|
||||||
if (!interestData?.data) return [];
|
if (!interestData) return [];
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: interestData.data.clientName ?? '',
|
name: interestData.clientName ?? '',
|
||||||
email: interestData.data.clientPrimaryEmail ?? '',
|
email: interestData.clientPrimaryEmail ?? '',
|
||||||
role: 'client' as const,
|
role: 'client' as const,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -124,7 +131,7 @@ export function ExternalEoiUploadDialog({
|
|||||||
.map((b) => b.mooringNumber)
|
.map((b) => b.mooringNumber)
|
||||||
.filter((m): m is string => !!m);
|
.filter((m): m is string => !!m);
|
||||||
const berthLabel = moorings.length > 0 ? formatBerthRange(moorings) : null;
|
const berthLabel = moorings.length > 0 ? formatBerthRange(moorings) : null;
|
||||||
const clientName = interestData?.data?.clientName ?? null;
|
const clientName = interestData?.clientName ?? null;
|
||||||
const parts = ['External EOI'];
|
const parts = ['External EOI'];
|
||||||
if (clientName) parts.push(clientName);
|
if (clientName) parts.push(clientName);
|
||||||
if (berthLabel) parts.push(berthLabel);
|
if (berthLabel) parts.push(berthLabel);
|
||||||
|
|||||||
@@ -318,7 +318,13 @@ export function CommandSearch() {
|
|||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label="Search results"
|
aria-label="Search results"
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-[calc(100%+4px)] left-0 z-50 rounded-md border bg-popover shadow-lg overflow-hidden',
|
// Explicit white/popover background pair guards against subtle
|
||||||
|
// transparency artifacts UAT 2026-05-24 captured (rows on the
|
||||||
|
// Berths page bled through to the table behind even though
|
||||||
|
// bg-popover resolves opaque on paper). bg-white wins the
|
||||||
|
// tailwind-merge race so light mode is unambiguously solid;
|
||||||
|
// dark mode falls through to bg-popover via the dark: prefix.
|
||||||
|
'absolute top-[calc(100%+4px)] left-0 z-50 rounded-md border bg-white dark:bg-popover shadow-lg overflow-hidden',
|
||||||
// Desktop: anchored to the input width, capped on viewport
|
// Desktop: anchored to the input width, capped on viewport
|
||||||
'w-full max-w-[min(720px,calc(100vw-2rem))]',
|
'w-full max-w-[min(720px,calc(100vw-2rem))]',
|
||||||
// Mobile (<lg): full-screen sheet so cramped phone widths
|
// Mobile (<lg): full-screen sheet so cramped phone widths
|
||||||
|
|||||||
151
tests/integration/external-eoi-stage-advance.test.ts
Normal file
151
tests/integration/external-eoi-stage-advance.test.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Integration test: external-EOI upload stage-advance gate.
|
||||||
|
*
|
||||||
|
* Regression coverage for the 2026-05-24 critical bug — the previous code
|
||||||
|
* gated on the legacy 9-stage vocabulary (`open` / `details_sent` /
|
||||||
|
* `in_communication` / `eoi_sent`), so uploads against canonical pre-EOI
|
||||||
|
* stages (`enquiry`, `qualified`, `nurturing`) left `pipeline_stage` stuck
|
||||||
|
* while `eoi_status` flipped to `signed`.
|
||||||
|
*
|
||||||
|
* Asserts:
|
||||||
|
* - canonical pre-EOI stages (enquiry / qualified / nurturing) advance to `eoi`
|
||||||
|
* - at-or-past-EOI stages (eoi / reservation / deposit_paid / contract) stay put
|
||||||
|
* - document-metadata writes (dateEoiSigned, eoiStatus, eoiDocStatus) fire on every path
|
||||||
|
* - the service return reports stageChanged + newStage accurately
|
||||||
|
*
|
||||||
|
* Storage is mocked to a no-op MinIO put so the service can run without S3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
let uploadExternallySignedEoi: typeof import('@/lib/services/external-eoi.service').uploadExternallySignedEoi;
|
||||||
|
let createInterest: typeof import('@/lib/services/interests.service').createInterest;
|
||||||
|
let makePort: typeof import('../helpers/factories').makePort;
|
||||||
|
let makeClient: typeof import('../helpers/factories').makeClient;
|
||||||
|
let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const ext = await import('@/lib/services/external-eoi.service');
|
||||||
|
uploadExternallySignedEoi = ext.uploadExternallySignedEoi;
|
||||||
|
const svc = await import('@/lib/services/interests.service');
|
||||||
|
createInterest = svc.createInterest;
|
||||||
|
const factories = await import('../helpers/factories');
|
||||||
|
makePort = factories.makePort;
|
||||||
|
makeClient = factories.makeClient;
|
||||||
|
makeAuditMeta = factories.makeAuditMeta;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('uploadExternallySignedEoi — stage-advance gate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.doMock('@/lib/storage', async () => {
|
||||||
|
const real = await vi.importActual<typeof import('@/lib/storage')>('@/lib/storage');
|
||||||
|
return {
|
||||||
|
...real,
|
||||||
|
getStorageBackend: vi.fn(async () => ({
|
||||||
|
put: vi.fn(async () => undefined),
|
||||||
|
get: vi.fn(),
|
||||||
|
head: vi.fn(),
|
||||||
|
delete: vi.fn(async () => undefined),
|
||||||
|
listByPrefix: vi.fn(async () => []),
|
||||||
|
presignUpload: vi.fn(async () => ''),
|
||||||
|
presignDownload: vi.fn(async () => ''),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.doUnmock('@/lib/storage');
|
||||||
|
});
|
||||||
|
|
||||||
|
async function makeInterest(
|
||||||
|
stage:
|
||||||
|
| 'enquiry'
|
||||||
|
| 'qualified'
|
||||||
|
| 'nurturing'
|
||||||
|
| 'eoi'
|
||||||
|
| 'reservation'
|
||||||
|
| 'deposit_paid'
|
||||||
|
| 'contract',
|
||||||
|
) {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
const meta = makeAuditMeta({ portId: port.id });
|
||||||
|
|
||||||
|
const created = await createInterest(
|
||||||
|
port.id,
|
||||||
|
{ clientId: client.id, pipelineStage: 'enquiry', reminderEnabled: false, tagIds: [] },
|
||||||
|
meta,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set stage directly via DB to bypass the yacht-required gate on
|
||||||
|
// changeInterestStage — this test focuses solely on the external-EOI
|
||||||
|
// advance behaviour given an arbitrary starting stage, not on the
|
||||||
|
// separate yacht-link gate that fronts manual stage transitions.
|
||||||
|
if (stage !== 'enquiry') {
|
||||||
|
await db.update(interests).set({ pipelineStage: stage }).where(eq(interests.id, created.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { port, client, meta, interest: created };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFakeEoi(
|
||||||
|
interestId: string,
|
||||||
|
portId: string,
|
||||||
|
meta: ReturnType<typeof makeAuditMeta>,
|
||||||
|
) {
|
||||||
|
return uploadExternallySignedEoi({
|
||||||
|
interestId,
|
||||||
|
portId,
|
||||||
|
fileData: {
|
||||||
|
buffer: Buffer.from('%PDF-1.4\n%fake\n'),
|
||||||
|
originalName: 'signed.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
size: 16,
|
||||||
|
},
|
||||||
|
signedAt: new Date('2026-05-24T10:00:00Z'),
|
||||||
|
signatories: [{ name: 'Client', email: 'client@example.com', role: 'client' }],
|
||||||
|
meta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each(['enquiry', 'qualified', 'nurturing'] as const)(
|
||||||
|
'advances %s → eoi',
|
||||||
|
async (startStage) => {
|
||||||
|
const { port, meta, interest } = await makeInterest(startStage);
|
||||||
|
|
||||||
|
const result = await uploadFakeEoi(interest.id, port.id, meta);
|
||||||
|
|
||||||
|
expect(result.stageChanged).toBe(true);
|
||||||
|
expect(result.newStage).toBe('eoi');
|
||||||
|
|
||||||
|
const row = await db.query.interests.findFirst({ where: eq(interests.id, interest.id) });
|
||||||
|
expect(row?.pipelineStage).toBe('eoi');
|
||||||
|
expect(row?.eoiStatus).toBe('signed');
|
||||||
|
expect(row?.eoiDocStatus).toBe('signed');
|
||||||
|
expect(row?.dateEoiSigned).toBeTruthy();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['eoi', 'reservation', 'deposit_paid', 'contract'] as const)(
|
||||||
|
'leaves %s untouched (metadata still writes)',
|
||||||
|
async (startStage) => {
|
||||||
|
const { port, meta, interest } = await makeInterest(startStage);
|
||||||
|
|
||||||
|
const result = await uploadFakeEoi(interest.id, port.id, meta);
|
||||||
|
|
||||||
|
expect(result.stageChanged).toBe(false);
|
||||||
|
expect(result.newStage).toBe(startStage);
|
||||||
|
|
||||||
|
const row = await db.query.interests.findFirst({ where: eq(interests.id, interest.id) });
|
||||||
|
expect(row?.pipelineStage).toBe(startStage);
|
||||||
|
expect(row?.eoiStatus).toBe('signed');
|
||||||
|
expect(row?.eoiDocStatus).toBe('signed');
|
||||||
|
expect(row?.dateEoiSigned).toBeTruthy();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user