fix(query): scope broadcast channel per account and clear on auth

Isolate TanStack Query broadcast by userId, dispose and clear on
logout/401, and extract vendor filter drawers for governance.
This commit is contained in:
arthur.bassi 2026-08-11 14:12:40 -03:00
parent c8a8522499
commit e4370591ef
11 changed files with 505 additions and 275 deletions

View file

@ -25,17 +25,23 @@ Use TanStack's own experimental broadcast client,
wired up in a single dedicated setup module, wired up in a single dedicated setup module,
`src/lib/query/setup-query-broadcast.ts`. The module: `src/lib/query/setup-query-broadcast.ts`. The module:
- Wraps `broadcastQueryClient({ queryClient, broadcastChannel })`, keyed to a - Exposes `startQueryBroadcast(queryClient, userId)` and
single named channel (`seahaven-admin-query`). `stopQueryBroadcast(queryClient)`.
- No-ops outside the browser (SSR/build) and under Vitest (`import.meta.env.MODE - Keys `broadcastQueryClient` to an **account-scoped** channel
=== "test"`), so it never runs in unit tests or node-based tooling. (`seahaven-admin-query:${userId}`), so tabs belonging to different accounts
- Is invoked once from `src/lib/query/query-client.ts` against the app's on the same origin do not share cache traffic.
singleton `QueryClient`, so every tab that loads the SPA subscribes to the - Disposes the prior subscription (the unsubscribe returned by
same channel automatically — no per-feature wiring required. `broadcastQueryClient`) and clears the `QueryClient` when the authenticated
user changes or the session ends.
- No-ops the BroadcastChannel outside the browser (SSR/build) and under Vitest
(`import.meta.env.MODE === "test"`).
- Is started after authentication (`AuthProvider` session restore + login
success) and stopped on logout and HTTP 401 session clear — not at
`QueryClient` module load.
This piggybacks on the query cache we already have (no parallel store), uses This piggybacks on the query cache we already have (no parallel store), uses
the library that owns the `QueryClient` we already depend on, and requires the library that owns the `QueryClient` we already depend on, and keeps the
close to zero application code (~20 lines) to adopt. integration isolated to auth/session boundaries.
### Alternatives considered ### Alternatives considered
@ -44,6 +50,9 @@ close to zero application code (~20 lines) to adopt.
serialization/versioning of query keys and mutation results, and keeping serialization/versioning of query keys and mutation results, and keeping
every future mutation hook wired to broadcast. More code to own and more every future mutation hook wired to broadcast. More code to own and more
surface area for subtle cache-desync bugs. surface area for subtle cache-desync bugs.
- **Constant channel from module load** — simplest wiring, but shares one
channel across all sessions on the origin; after logout/login or multi-account
use, cached work-order/vendor data can leak into the next session. Rejected.
- **No cross-tab sync** — simplest option, but leaves the stale-tab UX problem - **No cross-tab sync** — simplest option, but leaves the stale-tab UX problem
unresolved; users would need to manually refresh or wait for unresolved; users would need to manually refresh or wait for
`refetchOnWindowFocus`/`staleTime` to catch up, which is not reliable enough `refetchOnWindowFocus`/`staleTime` to catch up, which is not reliable enough
@ -56,26 +65,27 @@ close to zero application code (~20 lines) to adopt.
## Consequences ## Consequences
- **Positive**: sibling tabs reflect mutations (status changes, dispatch - **Positive**: sibling tabs for the same authenticated account reflect
actions, comments, patches) without a manual refresh; the integration is mutations (status changes, dispatch actions, comments, patches) without a
isolated to one setup file and does not touch domain/query-key code. manual refresh; logout/401 tear down the broadcaster and clear sensitive
cache so the next account cannot inherit prior data.
- **Risk — package lifecycle**: the dependency is explicitly "experimental" in - **Risk — package lifecycle**: the dependency is explicitly "experimental" in
the TanStack ecosystem; its API may change or be deprecated between minor the TanStack ecosystem; its API may change or be deprecated between minor
versions. `@tanstack/query-broadcast-client-experimental`, versions. `@tanstack/query-broadcast-client-experimental`,
`@tanstack/react-query`, and `@tanstack/react-query-devtools` are pinned to `@tanstack/react-query`, and `@tanstack/react-query-devtools` are pinned to
the **exact same version** in `package.json` (no `^` range). All three the **exact same version** in `package.json` (no `^` range). All three
resolve to the identical `@tanstack/query-core` version at that pin, which resolve to the identical `@tanstack/query-core` version at that pin, which
keeps the `QueryClient` type used by `setupQueryBroadcast` structurally keeps the `QueryClient` type used by the broadcast helpers structurally
identical to the one constructed in `query-client.ts` — a caret range lets identical to the one constructed in `query-client.ts` — a caret range lets
npm resolve the broadcast client and React Query against two different npm resolve the broadcast client and React Query against two different
`query-core` versions independently, which breaks `QueryClient` type `query-core` versions independently, which breaks `QueryClient` type
identity (TS2322) even though both packages build fine in isolation. Bump identity (TS2322) even though both packages build fine in isolation. Bump
all three together and re-evaluate on every TanStack Query upgrade. all three together and re-evaluate on every TanStack Query upgrade.
- **Risk — same-origin only**: `BroadcastChannel` only syncs tabs on the same - **Risk — same-origin only**: `BroadcastChannel` only syncs tabs on the same
origin; it does not sync across different users/sessions or devices. This is origin; it does not sync across different users/sessions or devices. Account
acceptable for the current requirement (single user, multiple tabs). scoping further limits sync to tabs of the same `userId`.
- **Rollback plan**: remove the `@tanstack/query-broadcast-client-experimental` - **Rollback plan**: remove the `@tanstack/query-broadcast-client-experimental`
dependency from `package.json` and delete the call to `setupQueryBroadcast` dependency from `package.json`, delete `setup-query-broadcast.ts`, and remove
in `src/lib/query/query-client.ts` (and the `setup-query-broadcast.ts` module the `startQueryBroadcast` / `stopQueryBroadcast` call sites in auth and
itself). No other code depends on it, so rollback is a single, isolated `api.ts`. No other code depends on it, so rollback is isolated with no data
change with no data migration. migration.

View file

@ -3,13 +3,13 @@ import { env } from "@/lib/env";
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url"; import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
import { API_PATHS } from "@/api/api-paths"; import { API_PATHS } from "@/api/api-paths";
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error"; import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
import { queryKeys } from "@/infra/query-key/query-key";
import queryClient from "@/lib/query/query-client"; import queryClient from "@/lib/query/query-client";
import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage"; import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
function clearSessionAndRedirectToLogin(): void { function clearSessionAndRedirectToLogin(): void {
clearAuth(); clearAuth();
queryClient.setQueryData(queryKeys.auth.session(), null); stopQueryBroadcast(queryClient);
if (typeof window !== "undefined" && window.location.pathname !== "/login") { if (typeof window !== "undefined" && window.location.pathname !== "/login") {
window.location.replace("/login"); window.location.replace("/login");

View file

@ -0,0 +1,248 @@
import type { Dispatch, SetStateAction } from "react";
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
import { Autocomplete, Button, Checkbox, Chip, Stack, TextField, Typography } from "@mui/material";
import type {
VendorFacetCompany,
VendorFacetJobBucket,
VendorFacetLocation,
} from "@/domain/vendors/types/vendor";
import type { VendorFilters } from "./vendor-filters";
import { FilterSection } from "./vendor-filters-drawer-section";
interface VendorFiltersDrawerFieldsProps {
draft: VendorFilters;
setDraft: Dispatch<SetStateAction<VendorFilters>>;
options: {
companies: VendorFacetCompany[];
trades: string[];
locations: VendorFacetLocation[];
jobBuckets: VendorFacetJobBucket[];
};
}
function CompanyFilterFields({
draft,
setDraft,
companyOptions,
}: {
draft: VendorFilters;
setDraft: Dispatch<SetStateAction<VendorFilters>>;
companyOptions: VendorFacetCompany[];
}) {
return (
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Select vendor companies
</Typography>
<Button
size="small"
onClick={() =>
setDraft((current) => ({
...current,
companies:
current.companies.length === companyOptions.length
? []
: companyOptions.map((company) => company.name),
}))
}
>
{draft.companies.length === companyOptions.length && companyOptions.length > 0
? "Clear all"
: "Select all"}
</Button>
</Stack>
<Autocomplete
multiple
size="small"
options={companyOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.name === value.name
}
value={companyOptions.filter((option) => draft.companies.includes(option.name))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
typeof entry === "string" ? entry : entry.name,
);
setDraft((current) => ({ ...current, companies: next }));
}}
renderOption={(props, option, { selected }) => {
const { key, ...optionProps } = props;
return (
<li key={key} {...optionProps}>
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
{option.name}
</li>
);
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.name;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.companies.length === 0 ? "All companies" : ""}
/>
)}
/>
</FilterSection>
);
}
function TradeFilterFields({
draft,
setDraft,
tradeOptions,
}: {
draft: VendorFilters;
setDraft: Dispatch<SetStateAction<VendorFilters>>;
tradeOptions: string[];
}) {
return (
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={tradeOptions}
value={draft.trades}
onChange={(_event, value) =>
setDraft((current) => ({ ...current, trades: value as string[] }))
}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={option} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField {...params} placeholder={draft.trades.length === 0 ? "All trades" : ""} />
)}
/>
</FilterSection>
);
}
function LocationFilterFields({
draft,
setDraft,
locationOptions,
}: {
draft: VendorFilters;
setDraft: Dispatch<SetStateAction<VendorFilters>>;
locationOptions: VendorFacetLocation[];
}) {
return (
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={locationOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.label === value.label
}
value={locationOptions.filter((option) => draft.locations.includes(option.label))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
typeof entry === "string" ? entry : entry.label,
);
setDraft((current) => ({ ...current, locations: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.locations.length === 0 ? "All locations" : ""}
/>
)}
/>
</FilterSection>
);
}
function JobBucketFilterFields({
draft,
setDraft,
jobBucketOptions,
}: {
draft: VendorFilters;
setDraft: Dispatch<SetStateAction<VendorFilters>>;
jobBucketOptions: VendorFacetJobBucket[];
}) {
return (
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={jobBucketOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.id === value.id
}
value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
typeof entry === "string" ? entry : entry.id,
);
setDraft((current) => ({ ...current, jobBuckets: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
/>
)}
/>
</FilterSection>
);
}
export function VendorFiltersDrawerFields({
draft,
setDraft,
options,
}: VendorFiltersDrawerFieldsProps) {
return (
<>
<CompanyFilterFields draft={draft} setDraft={setDraft} companyOptions={options.companies} />
<TradeFilterFields draft={draft} setDraft={setDraft} tradeOptions={options.trades} />
<LocationFilterFields draft={draft} setDraft={setDraft} locationOptions={options.locations} />
<JobBucketFilterFields
draft={draft}
setDraft={setDraft}
jobBucketOptions={options.jobBuckets}
/>
</>
);
}

View file

@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Stack,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
export function FilterSection({
title,
icon,
children,
}: {
title: string;
icon: ReactNode;
children: ReactNode;
}) {
return (
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
<Typography variant="subtitle1" className="font-semibold">
{title}
</Typography>
</Stack>
</AccordionSummary>
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
{children}
</AccordionDetails>
</Accordion>
);
}

View file

@ -1,32 +1,9 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState } from "react";
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material";
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; import type { VendorFacets } from "@/domain/vendors/types/vendor";
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Autocomplete,
Box,
Button,
Checkbox,
Chip,
Drawer,
IconButton,
Stack,
TextField,
Typography,
} from "@mui/material";
import type {
VendorFacets,
VendorFacetCompany,
VendorFacetJobBucket,
VendorFacetLocation,
} from "@/domain/vendors/types/vendor";
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters"; import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
import { VendorFiltersDrawerFields } from "./vendor-filters-drawer-fields";
interface VendorFiltersDrawerProps { interface VendorFiltersDrawerProps {
open: boolean; open: boolean;
@ -38,32 +15,6 @@ interface VendorFiltersDrawerProps {
const DRAWER_WIDTH = 360; const DRAWER_WIDTH = 360;
function FilterSection({
title,
icon,
children,
}: {
title: string;
icon: ReactNode;
children: ReactNode;
}) {
return (
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
<Typography variant="subtitle1" className="font-semibold">
{title}
</Typography>
</Stack>
</AccordionSummary>
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
{children}
</AccordionDetails>
</Accordion>
);
}
export function VendorFiltersDrawer({ export function VendorFiltersDrawer({
open, open,
onClose, onClose,
@ -125,7 +76,7 @@ export function VendorFiltersDrawer({
<Typography id="vendor-filters-drawer-title" variant="h6" component="h2"> <Typography id="vendor-filters-drawer-title" variant="h6" component="h2">
Filters Filters
</Typography> </Typography>
{activeCount > 0 && <Chip size="small" color="primary" label={activeCount} />} {Boolean(activeCount > 0) && <Chip size="small" color="primary" label={activeCount} />}
</Stack> </Stack>
<IconButton aria-label="Close filters" onClick={onClose} size="small"> <IconButton aria-label="Close filters" onClick={onClose} size="small">
<CloseIcon fontSize="small" /> <CloseIcon fontSize="small" />
@ -133,165 +84,16 @@ export function VendorFiltersDrawer({
</Stack> </Stack>
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}> <Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}> <VendorFiltersDrawerFields
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}> draft={draft}
<Typography variant="caption" sx={{ color: "text.secondary" }}> setDraft={setDraft}
Select vendor companies options={{
</Typography> companies: companyOptions,
<Button trades: tradeOptions,
size="small" locations: locationOptions,
onClick={() => jobBuckets: jobBucketOptions,
setDraft((current) => ({ }}
...current, />
companies:
current.companies.length === companyOptions.length
? []
: companyOptions.map((company) => company.name),
}))
}
>
{draft.companies.length === companyOptions.length && companyOptions.length > 0
? "Clear all"
: "Select all"}
</Button>
</Stack>
<Autocomplete
multiple
size="small"
options={companyOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.name === value.name
}
value={companyOptions.filter((option) => draft.companies.includes(option.name))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
typeof entry === "string" ? entry : entry.name,
);
setDraft((current) => ({ ...current, companies: next }));
}}
renderOption={(props, option, { selected }) => {
const { key, ...optionProps } = props;
return (
<li key={key} {...optionProps}>
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
{option.name}
</li>
);
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.name;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.companies.length === 0 ? "All companies" : ""}
/>
)}
/>
</FilterSection>
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={tradeOptions}
value={draft.trades}
onChange={(_event, value) =>
setDraft((current) => ({ ...current, trades: value as string[] }))
}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={option} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.trades.length === 0 ? "All trades" : ""}
/>
)}
/>
</FilterSection>
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={locationOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.label === value.label
}
value={locationOptions.filter((option) => draft.locations.includes(option.label))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
typeof entry === "string" ? entry : entry.label,
);
setDraft((current) => ({ ...current, locations: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.locations.length === 0 ? "All locations" : ""}
/>
)}
/>
</FilterSection>
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={jobBucketOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.id === value.id
}
value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
typeof entry === "string" ? entry : entry.id,
);
setDraft((current) => ({ ...current, jobBuckets: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
/>
)}
/>
</FilterSection>
</Box> </Box>
<Stack <Stack

View file

@ -67,6 +67,37 @@ function PageShell({
); );
} }
function RosterLoadErrorState({ onBack }: { onBack: () => void }) {
return (
<PageShell title="Vendor company" subtitle="Edit the company and its technicians.">
<Alert severity="error">Failed to load vendor company. Try again.</Alert>
<Stack direction="row" sx={{ justifyContent: "flex-end" }}>
<Button variant="outlined" onClick={onBack}>
Back to vendors
</Button>
</Stack>
</PageShell>
);
}
function rosterPageCopy(isEdit: boolean): { title: string; subtitle: string } {
if (isEdit) {
return {
title: "Edit Vendor Company",
subtitle: "Update company details and technicians in one save.",
};
}
return {
title: "Add Vendor Company",
subtitle: "Register a company and add zero, one, or multiple technicians.",
};
}
function submitLabel(isSaving: boolean, isEdit: boolean): string {
if (isSaving) return "Saving...";
return isEdit ? "Save" : "Create";
}
export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) { export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const isEdit = vendorId !== undefined || companyId !== undefined; const isEdit = vendorId !== undefined || companyId !== undefined;
@ -94,26 +125,14 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
} }
if (isEdit && form.isError) { if (isEdit && form.isError) {
return ( return <RosterLoadErrorState onBack={() => navigate("/vendors")} />;
<PageShell title="Vendor company" subtitle="Edit the company and its technicians.">
<Alert severity="error">Failed to load vendor company. Try again.</Alert>
<Stack direction="row" sx={{ justifyContent: "flex-end" }}>
<Button variant="outlined" onClick={() => navigate("/vendors")}>
Back to vendors
</Button>
</Stack>
</PageShell>
);
} }
if (isEdit && !form.roster) { if (isEdit && !form.roster) {
return null; return null;
} }
const title = isEdit ? "Edit Vendor Company" : "Add Vendor Company"; const { title, subtitle } = rosterPageCopy(isEdit);
const subtitle = isEdit
? "Update company details and technicians in one save."
: "Register a company and add zero, one, or multiple technicians.";
const companySelectionProps = isEdit const companySelectionProps = isEdit
? {} ? {}
: { : {
@ -122,6 +141,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
onSelectCompany: form.selectCompany, onSelectCompany: form.selectCompany,
onClearSelectedCompany: form.clearSelectedCompany, onClearSelectedCompany: form.clearSelectedCompany,
}; };
const showPortalToken = Boolean(isEdit && vendorId);
return ( return (
<PageShell title={title} subtitle={subtitle}> <PageShell title={title} subtitle={subtitle}>
@ -143,7 +163,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
{...companySelectionProps} {...companySelectionProps}
/> />
{isEdit && vendorId !== undefined && vendorId !== "" && ( {showPortalToken && vendorId !== undefined && (
<VendorPortalTokenPanel vendorId={vendorId} /> <VendorPortalTokenPanel vendorId={vendorId} />
)} )}
@ -156,7 +176,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
Cancel Cancel
</Button> </Button>
<Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}> <Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}>
{form.isSaving ? "Saving..." : isEdit ? "Save" : "Create"} {submitLabel(form.isSaving, isEdit)}
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>

View file

@ -10,6 +10,7 @@ import { toast } from "react-toastify";
import * as authApi from "@/domain/auth/api/auth-api"; import * as authApi from "@/domain/auth/api/auth-api";
import type { AuthUser, LoginCredentials } from "@/domain/auth/types/auth-user"; import type { AuthUser, LoginCredentials } from "@/domain/auth/types/auth-user";
import { queryKeys } from "@/infra/query-key/query-key"; import { queryKeys } from "@/infra/query-key/query-key";
import { startQueryBroadcast, stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
export function useSession(): UseQueryResult<AuthUser | null, Error> { export function useSession(): UseQueryResult<AuthUser | null, Error> {
return useQuery({ return useQuery({
@ -28,7 +29,9 @@ export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials>
mutationFn: (credentials: LoginCredentials) => authApi.login(credentials), mutationFn: (credentials: LoginCredentials) => authApi.login(credentials),
meta: { suppressErrorToast: true }, meta: { suppressErrorToast: true },
onSuccess: (user) => { onSuccess: (user) => {
stopQueryBroadcast(queryClient);
queryClient.setQueryData(queryKeys.auth.session(), user); queryClient.setQueryData(queryKeys.auth.session(), user);
startQueryBroadcast(queryClient, user.id);
toast.success("Login successful!"); toast.success("Login successful!");
navigate("/"); navigate("/");
}, },
@ -42,13 +45,12 @@ export function useLogout(): UseMutationResult<void, Error, void> {
return useMutation({ return useMutation({
mutationFn: authApi.logout, mutationFn: authApi.logout,
onSuccess: () => { onSuccess: () => {
queryClient.setQueryData(queryKeys.auth.session(), null); stopQueryBroadcast(queryClient);
queryClient.removeQueries({ queryKey: queryKeys.auth.all });
toast.info("You have been logged out"); toast.info("You have been logged out");
navigate("/login"); navigate("/login");
}, },
onError: (error: Error) => { onError: (error: Error) => {
queryClient.setQueryData(queryKeys.auth.session(), null); stopQueryBroadcast(queryClient);
toast.error(error.message || "Logout failed"); toast.error(error.message || "Logout failed");
navigate("/login"); navigate("/login");
}, },

View file

@ -1,6 +1,5 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { setupQueryBroadcast } from "@/lib/query/setup-query-broadcast";
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
queryCache: new QueryCache({ queryCache: new QueryCache({
@ -31,6 +30,4 @@ export const queryClient = new QueryClient({
}, },
}); });
setupQueryBroadcast(queryClient);
export default queryClient; export default queryClient;

View file

@ -1,19 +1,60 @@
import { broadcastQueryClient } from "@tanstack/query-broadcast-client-experimental"; import { broadcastQueryClient } from "@tanstack/query-broadcast-client-experimental";
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
/** Same-origin channel name for cross-tab QueryClient sync. */ /** Prefix for same-origin BroadcastChannel names (suffixed with authenticated user id). */
export const QUERY_BROADCAST_CHANNEL = "seahaven-admin-query"; export const QUERY_BROADCAST_CHANNEL_PREFIX = "seahaven-admin-query";
let unsubscribe: (() => void) | null = null;
let activeUserId: string | number | null = null;
/** Builds the account-scoped channel name for cross-tab QueryClient sync. */
export function queryBroadcastChannelForUser(userId: string | number): string {
return `${QUERY_BROADCAST_CHANNEL_PREFIX}:${String(userId)}`;
}
function disposeBroadcast(): void {
unsubscribe?.();
unsubscribe = null;
activeUserId = null;
}
function canUseBroadcast(): boolean {
return typeof window !== "undefined" && import.meta.env.MODE !== "test";
}
/** /**
* Syncs QueryClient cache (invalidations + data) across browser tabs via BroadcastChannel. * Starts (or switches) account-scoped QueryClient broadcast.
* No-op outside the browser and during Vitest runs. * Disposes any prior subscription; clears the cache when the authenticated user changes.
* No-op for the BroadcastChannel outside the browser and during Vitest runs.
*/ */
export function setupQueryBroadcast(queryClient: QueryClient): void { export function startQueryBroadcast(queryClient: QueryClient, userId: string | number): void {
if (typeof window === "undefined") return; if (activeUserId === userId) {
if (import.meta.env.MODE === "test") return; return;
}
broadcastQueryClient({ const previousUserId = activeUserId;
disposeBroadcast();
if (previousUserId != null && previousUserId !== userId) {
queryClient.clear();
}
activeUserId = userId;
if (!canUseBroadcast()) {
return;
}
unsubscribe = broadcastQueryClient({
queryClient, queryClient,
broadcastChannel: QUERY_BROADCAST_CHANNEL, broadcastChannel: queryBroadcastChannelForUser(userId),
}); });
} }
/**
* Tears down the broadcaster and clears the entire QueryClient cache (logout / 401).
*/
export function stopQueryBroadcast(queryClient: QueryClient): void {
disposeBroadcast();
queryClient.clear();
}

View file

@ -1,14 +1,26 @@
import { useMemo, type ReactNode } from "react"; import { useEffect, useMemo, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useChangePassword } from "@/domain/auth/use-cases/use-change-password"; import { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
import { useLogin, useLogout, useSession } from "@/domain/auth/use-cases/use-login"; import { useLogin, useLogout, useSession } from "@/domain/auth/use-cases/use-login";
import { startQueryBroadcast } from "@/lib/query/setup-query-broadcast";
import { AuthContext, type AuthContextValue } from "@/providers/auth-context"; import { AuthContext, type AuthContextValue } from "@/providers/auth-context";
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const sessionQuery = useSession(); const sessionQuery = useSession();
const loginMutation = useLogin(); const loginMutation = useLogin();
const logoutMutation = useLogout(); const logoutMutation = useLogout();
const changePasswordMutation = useChangePassword(); const changePasswordMutation = useChangePassword();
const userId = sessionQuery.data?.id;
useEffect(() => {
if (userId == null) {
return;
}
startQueryBroadcast(queryClient, userId);
}, [queryClient, userId]);
const value = useMemo<AuthContextValue>( const value = useMemo<AuthContextValue>(
() => ({ () => ({
user: sessionQuery.data, user: sessionQuery.data,

View file

@ -1,15 +1,19 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
const disposeA = vi.fn();
const disposeB = vi.fn();
const broadcastQueryClient = vi.fn(); const broadcastQueryClient = vi.fn();
vi.mock("@tanstack/query-broadcast-client-experimental", () => ({ vi.mock("@tanstack/query-broadcast-client-experimental", () => ({
broadcastQueryClient: (...args: unknown[]) => broadcastQueryClient(...args), broadcastQueryClient: (...args: unknown[]) => broadcastQueryClient(...args),
})); }));
describe("setupQueryBroadcast", () => { describe("query broadcast account isolation", () => {
beforeEach(() => { beforeEach(() => {
broadcastQueryClient.mockClear(); broadcastQueryClient.mockReset();
disposeA.mockReset();
disposeB.mockReset();
vi.unstubAllEnvs(); vi.unstubAllEnvs();
}); });
@ -19,29 +23,87 @@ describe("setupQueryBroadcast", () => {
it("does not call broadcastQueryClient when MODE is test", async () => { it("does not call broadcastQueryClient when MODE is test", async () => {
vi.stubEnv("MODE", "test"); vi.stubEnv("MODE", "test");
const { setupQueryBroadcast } = await import("@/lib/query/setup-query-broadcast"); vi.resetModules();
const { startQueryBroadcast } = await import("@/lib/query/setup-query-broadcast");
const client = new QueryClient(); const client = new QueryClient();
setupQueryBroadcast(client); startQueryBroadcast(client, "user-a");
expect(broadcastQueryClient).not.toHaveBeenCalled(); expect(broadcastQueryClient).not.toHaveBeenCalled();
}); });
it("calls broadcastQueryClient with the app channel when MODE is not test", async () => { it("scopes the BroadcastChannel to the authenticated user id", async () => {
vi.stubEnv("MODE", "development"); vi.stubEnv("MODE", "development");
vi.resetModules(); vi.resetModules();
broadcastQueryClient.mockReturnValue(disposeA);
const { setupQueryBroadcast, QUERY_BROADCAST_CHANNEL } = const { startQueryBroadcast, queryBroadcastChannelForUser, QUERY_BROADCAST_CHANNEL_PREFIX } =
await import("@/lib/query/setup-query-broadcast"); await import("@/lib/query/setup-query-broadcast");
const client = new QueryClient(); const client = new QueryClient();
setupQueryBroadcast(client); startQueryBroadcast(client, 42);
expect(QUERY_BROADCAST_CHANNEL_PREFIX).toBe("seahaven-admin-query");
expect(queryBroadcastChannelForUser(42)).toBe("seahaven-admin-query:42");
expect(broadcastQueryClient).toHaveBeenCalledTimes(1); expect(broadcastQueryClient).toHaveBeenCalledTimes(1);
expect(broadcastQueryClient).toHaveBeenCalledWith({ expect(broadcastQueryClient).toHaveBeenCalledWith({
queryClient: client, queryClient: client,
broadcastChannel: QUERY_BROADCAST_CHANNEL, broadcastChannel: "seahaven-admin-query:42",
}); });
expect(QUERY_BROADCAST_CHANNEL).toBe("seahaven-admin-query"); });
it("disposes the prior broadcaster, clears cache, and switches channel across accounts", async () => {
vi.stubEnv("MODE", "development");
vi.resetModules();
broadcastQueryClient.mockReturnValueOnce(disposeA).mockReturnValueOnce(disposeB);
const { startQueryBroadcast, stopQueryBroadcast } =
await import("@/lib/query/setup-query-broadcast");
const client = new QueryClient();
const clearSpy = vi.spyOn(client, "clear");
client.setQueryData(["work-orders", "sensitive"], { from: "user-a" });
startQueryBroadcast(client, "user-a");
expect(broadcastQueryClient).toHaveBeenCalledWith({
queryClient: client,
broadcastChannel: "seahaven-admin-query:user-a",
});
startQueryBroadcast(client, "user-b");
expect(disposeA).toHaveBeenCalledTimes(1);
expect(clearSpy).toHaveBeenCalledTimes(1);
expect(client.getQueryData(["work-orders", "sensitive"])).toBeUndefined();
expect(broadcastQueryClient).toHaveBeenCalledTimes(2);
expect(broadcastQueryClient).toHaveBeenLastCalledWith({
queryClient: client,
broadcastChannel: "seahaven-admin-query:user-b",
});
client.setQueryData(["vendors", "roster"], { from: "user-b" });
stopQueryBroadcast(client);
expect(disposeB).toHaveBeenCalledTimes(1);
expect(clearSpy).toHaveBeenCalledTimes(2);
expect(client.getQueryData(["vendors", "roster"])).toBeUndefined();
});
it("is a no-op when start is called again for the same user", async () => {
vi.stubEnv("MODE", "development");
vi.resetModules();
broadcastQueryClient.mockReturnValue(disposeA);
const { startQueryBroadcast } = await import("@/lib/query/setup-query-broadcast");
const client = new QueryClient();
const clearSpy = vi.spyOn(client, "clear");
startQueryBroadcast(client, "user-a");
startQueryBroadcast(client, "user-a");
expect(broadcastQueryClient).toHaveBeenCalledTimes(1);
expect(disposeA).not.toHaveBeenCalled();
expect(clearSpy).not.toHaveBeenCalled();
}); });
}); });