From abda844f4fa21906bab30299479e23bde1edfdeb Mon Sep 17 00:00:00 2001 From: important-new <75075215+important-new@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:39:59 -0400 Subject: [PATCH] fix(editor): ask for the human check when publishing from the full-screen editor The editor route is declared outside the workspace layout, so the provider that raises the human check was never mounted there: publishing from the editor in a new company's first 24 hours showed no check, and the report mail it queued was never vouched for, so the delivery pass held it until the window ends. The editor already fetches the session context for its preview link, so its loader now also returns the open cooling window and site key, the page mounts the provider, and the publish dialog takes the unlock time from a prop (it read it from a session context the editor does not have, so its new-company notice never showed there). Tests cover the loader, the dialog prop and the EditorHumanCheck component; that the route uses it is checked on the live deployment rather than by a unit test. The provider mount lives in a small EditorHumanCheck component (with its own test), so the route only gains a thin default-export wrapper, the import and one prop. File-size baseline for the editor route raised by 12 lines for exactly that wiring. chrome-allow: the check and the notice only appear for a SaaS company inside its first 24 hours, which a standalone dev server cannot produce; the fix is verified against the live SaaS after deploy Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01C8qkG1Y2agL9r9s3CXziwF --- .../editor/EditorHumanCheck.test.tsx | 41 +++++++++++++++++++ app/components/editor/EditorHumanCheck.tsx | 25 +++++++++++ app/components/editor/PublishModal.tsx | 11 ++++- .../publish-modal-cooling-notice.test.tsx | 6 +++ app/routes/inspection-edit.tsx | 14 ++++++- .../inspection-edit/loader.server.test.ts | 22 +++++++++- app/routes/inspection-edit/loader.server.ts | 7 +++- scripts/file-size-baseline.json | 2 +- 8 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 app/components/editor/EditorHumanCheck.test.tsx create mode 100644 app/components/editor/EditorHumanCheck.tsx diff --git a/app/components/editor/EditorHumanCheck.test.tsx b/app/components/editor/EditorHumanCheck.test.tsx new file mode 100644 index 000000000..b26320a50 --- /dev/null +++ b/app/components/editor/EditorHumanCheck.test.tsx @@ -0,0 +1,41 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; + +vi.mock("~/lib/turnstile", () => ({ useTurnstileWidget: () => undefined })); + +import { EditorHumanCheck } from "./EditorHumanCheck"; +import { useEnsureHuman } from "~/components/OutboundVerification"; + +function Publish({ onResult }: { onResult: (ok: boolean) => void }) { + const ensureHuman = useEnsureHuman(); + return ; +} + +const click = async () => { await act(async () => { fireEvent.click(screen.getByText("publish")); }); }; + +describe("EditorHumanCheck", () => { + it("makes a publish inside a new company's window ask for the check", async () => { + const onResult = vi.fn(); + render( + + + , + ); + await click(); + expect(screen.getByRole("dialog")).toBeTruthy(); + expect(onResult).not.toHaveBeenCalled(); + }); + + it("lets a publish through at once when there is no window", async () => { + const onResult = vi.fn(); + render( + + + , + ); + await click(); + expect(onResult).toHaveBeenCalledWith(true); + expect(screen.queryByRole("dialog")).toBeNull(); + }); +}); diff --git a/app/components/editor/EditorHumanCheck.tsx b/app/components/editor/EditorHumanCheck.tsx new file mode 100644 index 000000000..4652b7692 --- /dev/null +++ b/app/components/editor/EditorHumanCheck.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from "react"; +import { OutboundVerificationProvider } from "~/components/OutboundVerification"; + +/** + * Mounts the human check for the full-screen editor, which sits outside the + * workspace layout and so has none of its own. `coolingWindow` is what the editor's + * loader read from the session context: non-null only while a new company's first + * 24 hours are open. + */ +export function EditorHumanCheck({ + coolingWindow, + children, +}: { + coolingWindow: { unlockAtMs: number; turnstileSiteKey: string | null } | null; + children: ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/app/components/editor/PublishModal.tsx b/app/components/editor/PublishModal.tsx index 2a49c8f3f..e0c2c67be 100644 --- a/app/components/editor/PublishModal.tsx +++ b/app/components/editor/PublishModal.tsx @@ -21,6 +21,12 @@ export interface PublishModalProps { publishError: string | null; isSubmitting: boolean; onClose: () => void; + /** + * The instant a new company's cooling window closes, when the host route has it. + * The full-screen editor sits outside the workspace layout, so the session + * context this modal otherwise reads is absent there; it passes its own. + */ + coolingUnlockAtMs?: number | null; /** * Publish the report. `markComplete` also closes the order axis first, and * `extras` carries the fields collected below — the owning route must send them @@ -57,7 +63,7 @@ export interface PublishModalProps { onAutoSignToggle: (checked: boolean) => void; } -export function PublishModal({ open, progress, status, publishError, isSubmitting, onClose, onPublish, autoSign, onAutoSignToggle, isAmendment }: PublishModalProps) { +export function PublishModal({ open, progress, status, publishError, isSubmitting, onClose, onPublish, autoSign, onAutoSignToggle, isAmendment, coolingUnlockAtMs }: PublishModalProps) { // The order lifecycle does not gate publishing (report and order axes are // independent). When the on-site work is not yet marked complete, offer an // advisory choice — do both, or just publish — but never block. @@ -72,7 +78,8 @@ export function PublishModal({ open, progress, status, publishError, isSubmittin // Same `unlockAtMs` the banner reads, so the two cannot name different times; // it is non-null only while the window is open, and the server decides that // (see resolveCoolingWindowForSession) — no clock arithmetic here. - const unlockAtMs = useSessionContext()?.outboundCoolingWindow?.unlockAtMs ?? null; + const sessionUnlockAtMs = useSessionContext()?.outboundCoolingWindow?.unlockAtMs ?? null; + const unlockAtMs = coolingUnlockAtMs ?? sessionUnlockAtMs; const timeZone = useDisplayTimeZone(); const dtFormat = useChromeDateTimeFormat(); diff --git a/app/components/editor/publish-modal-cooling-notice.test.tsx b/app/components/editor/publish-modal-cooling-notice.test.tsx index 69e60d0cc..c270507bf 100644 --- a/app/components/editor/publish-modal-cooling-notice.test.tsx +++ b/app/components/editor/publish-modal-cooling-notice.test.tsx @@ -39,6 +39,12 @@ const props = { }; describe('PublishModal — outbound cooling window', () => { + it('shows the notice from its own prop when the host route has no session context (the full-screen editor)', () => { + unlockAtMs = null; + render(); + expect(screen.getByText(/less than a day old/i)).toBeTruthy(); + }); + it('says nothing when the company is not in a window', () => { unlockAtMs = null; render(); diff --git a/app/routes/inspection-edit.tsx b/app/routes/inspection-edit.tsx index 9081e0d2b..255a1ef6e 100644 --- a/app/routes/inspection-edit.tsx +++ b/app/routes/inspection-edit.tsx @@ -8,6 +8,7 @@ import { findingKey } from "~/hooks/findings/shared"; import { useDisplayLocale } from "~/hooks/useSessionContext"; import { useGuardedSubmit } from "~/hooks/useGuardedSubmit"; import { useEnsureHuman } from "~/components/OutboundVerification"; +import { EditorHumanCheck } from "~/components/editor/EditorHumanCheck"; import { useFindings, type AttachedRepairItem } from "~/hooks/useFindings"; import { usePhotoOps } from "~/hooks/usePhotoOps"; import { useScopeLoader } from "~/hooks/useScopeLoader"; @@ -146,7 +147,17 @@ export { action } from "./inspection-edit/action.server"; /* Component */ /* ------------------------------------------------------------------ */ -export default function InspectionEditPage() { +/** The editor sits outside the workspace layout, so it mounts the human check itself. */ +export default function InspectionEditRoute() { + const { outboundCoolingWindow } = useLoaderData(); + return ( + + + + ); +} + +function InspectionEditPage() { const loaderData = useLoaderData(); const displayLocale = useDisplayLocale(); const fetcher = useFetcher(); @@ -1876,6 +1887,7 @@ export default function InspectionEditPage() { status={state.inspection.status as string} publishError={publishError} isSubmitting={publishBusy} isAmendment={loaderData.nextPublishIsAmendment} + coolingUnlockAtMs={loaderData.outboundCoolingWindow?.unlockAtMs ?? null} onClose={() => { setPublishError(null); state.setShowPublishModal(false); }} onPublish={async (markComplete: boolean, extras: PublishExtras) => { if (!(await ensureHuman())) return; diff --git a/app/routes/inspection-edit/loader.server.test.ts b/app/routes/inspection-edit/loader.server.test.ts index 733b046f0..006033220 100644 --- a/app/routes/inspection-edit/loader.server.test.ts +++ b/app/routes/inspection-edit/loader.server.test.ts @@ -30,6 +30,7 @@ const reportDataGet = vi.fn(); * — so it is the default, and the tests that care set it. */ let statutoryDetailsResponse: Response | null = null; +let sessionContextBody: unknown = null; // a Response body is single-use, so the stub builds one per call vi.mock('~/lib/session.server', () => ({ requireToken: vi.fn().mockResolvedValue('tok'), @@ -64,7 +65,7 @@ vi.mock('~/lib/api-client.server', () => ({ }, }, tags: { index: { $get: async () => null } }, - sessionContext: { context: { $get: async () => null } }, + sessionContext: { context: { $get: async () => (sessionContextBody === null ? null : json(sessionContextBody)) } }, defectCategories: { 'defect-categories': { $get: async () => null } }, })), })); @@ -137,6 +138,7 @@ beforeEach(() => { ratingLevels: [], })); statutoryDetailsResponse = null; // the ordinary inspection: no statutory form + sessionContextBody = null; }); describe('editor loader — item attributes survive the report-data overlay', () => { @@ -269,3 +271,21 @@ describe('editor loader — nextPublishIsAmendment', () => { expect(data.nextPublishIsAmendment).toBe(false); }); }); + +describe('loader — the human check for a new company', () => { + it('passes the open cooling window and the site key through, so the editor can ask for the check', async () => { + sessionContextBody = { outboundCoolingWindow: { unlockAtMs: 1_800_000_000_000, turnstileSiteKey: 'site-key-1' } }; + const data = await loadAll(); + expect(data.outboundCoolingWindow).toEqual({ unlockAtMs: 1_800_000_000_000, turnstileSiteKey: 'site-key-1' }); + }); + + // One load per test: the other stubs hand out single-use Response bodies. + it.each([ + ['the window has closed', { outboundCoolingWindow: null }], + ['the field is absent', {}], + ['the session context could not be read', null], + ])('is null when %s', async (_name, body) => { + sessionContextBody = body; + expect((await loadAll()).outboundCoolingWindow).toBeNull(); + }); +}); diff --git a/app/routes/inspection-edit/loader.server.ts b/app/routes/inspection-edit/loader.server.ts index 550ff8f8e..5465e9d63 100644 --- a/app/routes/inspection-edit/loader.server.ts +++ b/app/routes/inspection-edit/loader.server.ts @@ -207,14 +207,19 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { let tenantSlug: string | null = null; let videoProvider: "r2" | "stream" = "r2"; let collabEditing = false; + // The full-screen editor is declared outside the workspace layout, so it has no + // session context of its own; this is what lets it ask for the human check. + let outboundCoolingWindow: { unlockAtMs: number; turnstileSiteKey: string | null } | null = null; if (sessRes?.ok) { const sb = await sessRes.json() as { data?: { branding?: { tenantSlug?: string | null }; videoProvider?: "r2" | "stream"; collabEditing?: boolean; + outboundCoolingWindow?: { unlockAtMs: number; turnstileSiteKey: string | null } | null; }; }; + outboundCoolingWindow = sb.data?.outboundCoolingWindow ?? null; tenantSlug = sb.data?.branding?.tenantSlug ?? null; // Plan 7 — resolved video backend provider for this tenant (default 'r2'). // Drives VideoCapture/VideoPlayer branch selection in the editor. @@ -371,5 +376,5 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { // `report_versions.length`; both mean "a version already exists". const nextPublishIsAmendment = isReportPublished(inspection.reportStatus); - return { inspection, schema, results, resultId, ratingLevels, token, tagLibrary, tenantSlug, streamCustomerSubdomain, videoProvider, collabEditing, templateSnapshot, revisionStatus, pcaNarrative, defectCategories, units, unitProgress, unitInspectionMode, compliance, relianceText, commercialPresets, statutoryDetails, statutoryCoverage, nextPublishIsAmendment }; + return { inspection, schema, results, resultId, ratingLevels, token, tagLibrary, tenantSlug, streamCustomerSubdomain, videoProvider, collabEditing, outboundCoolingWindow, templateSnapshot, revisionStatus, pcaNarrative, defectCategories, units, unitProgress, unitInspectionMode, compliance, relianceText, commercialPresets, statutoryDetails, statutoryCoverage, nextPublishIsAmendment }; } diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 705dcf863..633dbeeb3 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -1,5 +1,5 @@ { - "app/routes/inspection-edit.tsx": 2605, + "app/routes/inspection-edit.tsx": 2617, "app/routes/inspector-portal.tsx": 1396, "scripts/verify-statutory-render.mjs": 1283, "server/durable-objects/inspection-doc.ts": 1028,