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>
51 lines
1.5 KiB
TypeScript
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,
|
|
},
|
|
},
|
|
});
|