Implement intercity document handling and rejection notes for contracts

This commit is contained in:
Marshal
2026-07-21 10:20:36 +00:00
226 changed files with 13854 additions and 2973 deletions

View File

@@ -1,5 +1,13 @@
import { api } from "./http";
import type { AuthTokens, AuthUser, LoginResponse } from "./types";
import type {
AuthTokens,
AuthUser,
ForgotPasswordRequestPayload,
ForgotPasswordVerifyPayload,
LoginResponse,
ResetTicket,
SetPasswordPayload,
} from "./types";
export const loginRequest = async (payload: {
email: string;
@@ -21,3 +29,31 @@ export const getMeRequest = async () => {
const response = await api.get<AuthUser>("/me");
return response.data;
};
// The three calls below drive the unauthenticated forgot-password flow.
// Responses under /api/auth are *flattened* by the API's response
// interceptor ({ success, ...payload }), so there is no `.data.data` here.
export const requestPasswordResetRequest = async (
payload: ForgotPasswordRequestPayload,
) => {
await api.post("/auth/forgot-password/request", payload);
};
export const verifyPasswordResetOtpRequest = async (
payload: ForgotPasswordVerifyPayload,
) => {
const response = await api.post<ResetTicket>(
"/auth/forgot-password/verify",
payload,
);
return response.data;
};
/**
* Spend the reset ticket minted by {@link verifyPasswordResetOtpRequest}.
* Carries its own userId/verificationCode and never touches the session.
*/
export const resetPasswordRequest = async (payload: SetPasswordPayload) => {
await api.patch("/auth/set-password", payload);
};

View File

@@ -1,10 +1,7 @@
import axios from "axios";
import toast from "react-hot-toast";
import { API_BASE_URL } from "@/constants/apiConfig";
import {
emitApiError,
extractApiErrorPayload,
} from "@/components/errors/ApiErrorModal";
import { captureApiError } from "@/lib/posthog";
import {
AUTH_TOKEN_COOKIE,
@@ -14,14 +11,16 @@ import {
setCookie,
} from "./cookies";
import type { AuthTokens } from "./types";
import { extractApiErrorPayload } from "@/components/errors/ApiErrorModal";
declare module "axios" {
export interface AxiosRequestConfig {
/**
* When true, the response interceptor does NOT raise the global error modal
* for this request's failure. For calls the caller handles itself — e.g. a
* probe that is expected to 404 before falling back (GL clearance detail
* tries /contracts/:id then /bookings/:id). The rejection still propagates.
* When true, the response interceptor does NOT raise the global error
* toast for this request's failure. For calls the caller handles itself —
* e.g. a probe that is expected to 404 before falling back (GL clearance
* detail tries /contracts/:id then /bookings/:id). The rejection still
* propagates.
*/
suppressErrorModal?: boolean;
}
@@ -96,9 +95,8 @@ api.interceptors.response.use(
async (error) => {
const originalRequest = error.config as RetriableRequest | undefined;
// Report the failure to PostHog. Hooked here rather than inside
// `emitApiError`, which stays silent on suppressed paths (warehouse /
// mile / onboarding) — those failures still need reporting.
// Report the failure to PostHog, including on suppressErrorModal paths —
// those opt out of the user-facing toast, not of reporting.
// 401s are skipped: an expired session is refreshed below, not a defect.
if (!error.response || error.response.status !== 401) {
captureApiError(error);
@@ -112,19 +110,25 @@ api.interceptors.response.use(
originalRequest.url?.includes("/auth/mfa-verify") ||
originalRequest.url?.includes("/auth/refresh-token")
) {
// Surface the server's actual error message in the global error modal
// (401s are handled by the session-refresh flow, so skip them). A request
// may opt out via `suppressErrorModal` when it handles the failure itself.
// Surface the server's actual error message in a global toast — never
// the error modal (401s are handled by the session-refresh flow, so skip
// them). A request may opt out via `suppressErrorModal` when it handles
// the failure itself.
if (error.response && error.response.status !== 401) {
const payload = extractApiErrorPayload(error);
// Normalize the error's own `message` to the SERVER's actual message so
// every downstream `toast.error(err.message)` / MutationCache handler
// shows the real cause instead of "Request failed with status code NNN".
// Applies even on suppressErrorModal paths — only the modal is opted out.
// every downstream `toast.error(err.message)` handler shows the real
// cause instead of "Request failed with status code NNN". Applies even
// on suppressErrorModal paths — only the toast is opted out.
if (payload?.messages.length) {
(error as { message?: string }).message = payload.messages.join("\n");
const message = payload.messages.join("\n");
(error as { message?: string }).message = message;
// Keyed by message so a retried request replaces its toast instead
// of stacking duplicates.
if (!originalRequest?.suppressErrorModal) {
toast.error(message, { id: message });
}
}
if (payload && !originalRequest?.suppressErrorModal) emitApiError(payload);
}
return Promise.reject(error);
}

View File

@@ -58,6 +58,29 @@ export interface LoginResponse extends Partial<AuthTokens> {
mfaRequired?: boolean;
}
export interface ForgotPasswordRequestPayload {
/** Email, username, or E.164 phone — whatever the user typed, normalised. */
identifier: string;
}
export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload {
otp: string;
}
/** Single-use ticket to spend on `PATCH /api/auth/set-password`. */
export interface ResetTicket {
userId: string;
verificationCode: string;
}
export interface SetPasswordPayload {
newPassword: string;
confirmPassword: string;
userId: string;
email: string;
verificationCode: string;
}
// Additional types for Matrix form test
export interface User {
id: string;