social-reporting-tool/v2/operator-app/src/api/client.ts
DJP 5770b2579d Wire SPA + SSO redirect URI to /social-reports/ prefix; in-place cutover script
Phase A scaffolded the SPA at the bare origin (`/`); production lives behind
Apache at `/social-reports/`. Without these fixes, V2's built assets 404 and
Azure SSO rejects the redirect URI mismatch.

- Vite `base: /social-reports/` (overridable via VITE_BASE for dev).
- BrowserRouter basename = import.meta.env.BASE_URL.
- apiFetch + msal-browser script src + token-exchange URL all prefix BASE.
- MSAL redirectUri now matches V1's Azure-registered URI:
  `${origin}/social-reports/login.html`.
- New `<Route path="/login.html">` alias renders the same Login component
  so React Router matches the redirect URI when MSAL returns.

Deploy ergonomics (the user wants V1 gone from the server):
- v2/deploy/cutover-in-place.sh: run from /opt/social-reporting; stops V1,
  pulls main (v2/ appears, V1 dirs deleted), migrates secrets from V1's
  .env into v2/.env, swaps Apache, starts V2. Single command, no clone of
  a sibling dir needed.
- setup-v2.sh: PURGE_V1=true flag now cleans /opt/social-reporting and
  the V1 docker volume after V2 is healthy.
- rollback-to-v1.sh: re-clones the v1-archive branch when V1 is no longer
  on disk (REPO_URL required).

62/62 unit tests still pass; vite build emits assets under /social-reports/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:40:38 -04:00

51 lines
1.5 KiB
TypeScript

import { QueryClient } from '@tanstack/react-query';
export type ApiIssue = { path: (string | number)[]; message: string; code?: string };
export class ApiError extends Error {
status: number;
issues?: ApiIssue[];
constructor(status: number, message: string, issues?: ApiIssue[]) {
super(message);
this.status = status;
if (issues) this.issues = issues;
}
}
// API base mirrors the Vite `base` (e.g. `/social-reports/`) so requests resolve
// to the Apache-proxied backend rather than the bare origin.
const BASE = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, '');
export async function fetcher<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const apiPath = path.startsWith('/api') ? path : `/api${path.startsWith('/') ? path : `/${path}`}`;
const url = `${BASE}${apiPath}`;
const res = await fetch(url, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
...init,
});
if (!res.ok) {
let msg = res.statusText;
let issues: ApiIssue[] | undefined;
try {
const body = await res.json();
if (body?.error) msg = body.error;
if (Array.isArray(body?.issues)) issues = body.issues;
} catch {}
throw new ApiError(res.status, msg, issues);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: false,
},
},
});