From 63f638d1bba63dca9999c2161f72843e69a7bcf5 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 7 Jul 2026 08:52:35 +0000 Subject: [PATCH 01/10] fix: session expiry during usage and also login error message --- apps/edr-freight-web/backoffice/.env.example | 4 + .../backoffice/src/auth/AuthProvider.tsx | 16 ++++ .../backoffice/src/auth/http.ts | 39 +++++--- .../backoffice/src/auth/refreshScheduler.ts | 82 +++++++++++++++++ .../backoffice/src/utils/result.ts | 34 ++++++- apps/edr-freight-web/portal/src/App.tsx | 19 +++- apps/edr-freight-web/portal/src/utils/api.ts | 89 ++++++++++--------- .../portal/src/utils/refreshScheduler.ts | 81 +++++++++++++++++ .../portal/src/utils/result.ts | 34 ++++++- 9 files changed, 340 insertions(+), 58 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts create mode 100644 apps/edr-freight-web/portal/src/utils/refreshScheduler.ts diff --git a/apps/edr-freight-web/backoffice/.env.example b/apps/edr-freight-web/backoffice/.env.example index cbbdd289f..a5e34a35d 100644 --- a/apps/edr-freight-web/backoffice/.env.example +++ b/apps/edr-freight-web/backoffice/.env.example @@ -1,2 +1,6 @@ VITE_API_URL=http://localhost:3001 VITE_BASE_API_URL=http://localhost:3001 + +# Proactive token refresh cadence (minutes). Must stay well under the 60-min +# server session window. Default: 10. +VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10 diff --git a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx index 66834c9f5..8266d96d7 100644 --- a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx +++ b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx @@ -16,6 +16,10 @@ import { setCookie, } from "./cookies"; import { applyTokens } from "./http"; +import { + startTokenRefreshScheduler, + stopTokenRefreshScheduler, +} from "./refreshScheduler"; import type { AuthTokens, AuthUser } from "./types"; interface LoginPayload { @@ -99,6 +103,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { void bootstrap(); }, []); + // Keep the server session alive while a user is logged in. Runs after + // login, MFA verification, and page-reload bootstrap alike. + useEffect(() => { + if (!user) { + stopTokenRefreshScheduler(); + return; + } + + startTokenRefreshScheduler(); + return stopTokenRefreshScheduler; + }, [user]); + const value = useMemo( () => ({ user, diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index 5aa78e2d3..2b0cf1021 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -28,6 +28,30 @@ const applyTokens = ({ token, refreshToken }: AuthTokens) => { setCookie(REFRESH_TOKEN_COOKIE, refreshToken); }; +/** + * Single-flight token refresh: concurrent callers (the 401 interceptor and + * the proactive scheduler) share one in-flight request so the refresh token + * is only rotated once. Throws if no refresh token is stored or the server + * rejects it — callers decide how to end the session. + */ +const refreshSessionTokens = async (): Promise => { + const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); + if (!refreshToken) { + throw new Error("missing refresh token"); + } + + refreshPromise ??= api + .post("/auth/refresh-token", { refreshToken }) + .then((response) => response.data) + .finally(() => { + refreshPromise = null; + }); + + const tokens = await refreshPromise; + applyTokens(tokens); + return tokens; +}; + api.interceptors.request.use((config) => { const token = getCookie(AUTH_TOKEN_COOKIE); @@ -65,8 +89,7 @@ api.interceptors.response.use( return Promise.reject(error); } - const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); - if (!refreshToken) { + if (!getCookie(REFRESH_TOKEN_COOKIE)) { clearSessionCookies(); return Promise.reject(error); } @@ -74,15 +97,7 @@ api.interceptors.response.use( originalRequest._retry = true; try { - refreshPromise ??= api - .post("/auth/refresh-token", { refreshToken }) - .then((response) => response.data) - .finally(() => { - refreshPromise = null; - }); - - const tokens = await refreshPromise; - applyTokens(tokens); + const tokens = await refreshSessionTokens(); originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${tokens.token}`, @@ -97,4 +112,4 @@ api.interceptors.response.use( }, ); -export { api, applyTokens }; +export { api, applyTokens, refreshSessionTokens }; diff --git a/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts b/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts new file mode 100644 index 000000000..1d2c14db2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts @@ -0,0 +1,82 @@ +import { isAxiosError } from "axios"; + +import { + REFRESH_TOKEN_COOKIE, + clearSessionCookies, + getCookie, +} from "./cookies"; +import { refreshSessionTokens } from "./http"; + +/** + * Proactively refreshes the token pair on a fixed cadence so the server-side + * session (a sliding 1-hour window, extended only by /auth/refresh-token) is + * kept alive while the app is open. The 401 interceptor in http.ts remains + * the reactive fallback; both share the same single-flight refresh call. + * + * The interval MUST stay well under the server session window (60 min). + */ +const DEFAULT_INTERVAL_MINUTES = 10; + +const getIntervalMs = () => { + const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES); + return ( + (Number.isFinite(minutes) && minutes > 0 + ? minutes + : DEFAULT_INTERVAL_MINUTES) * 60_000 + ); +}; + +let timerId: number | null = null; +let lastRefreshAt = 0; + +const refreshNow = async () => { + if (!getCookie(REFRESH_TOKEN_COOKIE)) { + // Logged out elsewhere; nothing to keep alive. + stopTokenRefreshScheduler(); + return; + } + + try { + await refreshSessionTokens(); + lastRefreshAt = Date.now(); + } catch (error) { + // Network hiccups are retried on the next tick; only an explicit server + // rejection means the session is dead. + if (isAxiosError(error) && error.response) { + stopTokenRefreshScheduler(); + clearSessionCookies(); + window.location.replace("/auth"); + } + } +}; + +/** + * Browsers freeze timers in background tabs — a tab waking up past its + * refresh deadline refreshes immediately instead of waiting a full interval. + */ +const onVisibilityChange = () => { + if (document.visibilityState !== "visible") return; + if (Date.now() - lastRefreshAt >= getIntervalMs()) { + void refreshNow(); + } +}; + +export const startTokenRefreshScheduler = () => { + stopTokenRefreshScheduler(); + + // Token age is unknown here (fresh login vs. hours-old page reload), so + // refresh right away to extend the session window from "now". + lastRefreshAt = 0; + void refreshNow(); + + timerId = window.setInterval(() => void refreshNow(), getIntervalMs()); + document.addEventListener("visibilitychange", onVisibilityChange); +}; + +export const stopTokenRefreshScheduler = () => { + if (timerId !== null) { + window.clearInterval(timerId); + timerId = null; + } + document.removeEventListener("visibilitychange", onVisibilityChange); +}; diff --git a/apps/edr-freight-web/backoffice/src/utils/result.ts b/apps/edr-freight-web/backoffice/src/utils/result.ts index 3e9627445..54104442c 100644 --- a/apps/edr-freight-web/backoffice/src/utils/result.ts +++ b/apps/edr-freight-web/backoffice/src/utils/result.ts @@ -8,6 +8,32 @@ export type ApiError = { statusCode?: number; }; +/** + * Backend errors arrive as snake_case i18n-style codes (e.g. + * "unable_to_log_in"). Map the known ones to friendly copy and prettify + * anything else so raw codes never reach the UI. `code` stays raw for + * programmatic checks. + */ +const API_ERROR_MESSAGES: Record = { + unable_to_log_in: "Incorrect email or password.", + invalid_refresh_token: "Your session has expired. Please sign in again.", + session_expired: "Your session has expired. Please sign in again.", + session_not_found: "Your session has expired. Please sign in again.", + user_not_found: "No account found for these credentials.", +}; + +const SNAKE_CASE_CODE = /^[a-z0-9]+(?:_[a-z0-9]+)+$/; + +function humanizeApiMessage(raw: string): string { + const known = API_ERROR_MESSAGES[raw]; + if (known) return known; + if (SNAKE_CASE_CODE.test(raw)) { + const text = raw.replaceAll("_", " "); + return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`; + } + return raw; +} + export function extractApiError(err: unknown): ApiError { if (err && typeof err === "object") { const obj = err as Record; @@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError { const statusCode = response.status as number | undefined; const data = response.data as Record | undefined; return { - code: (data?.error as string) || (data?.message as string) || "api_error", - message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred", + code: (data?.message as string) || (data?.error as string) || "api_error", + message: humanizeApiMessage( + (data?.message as string) || + (data?.error as string) || + "An unexpected error occurred", + ), statusCode, }; } diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 7567c485d..697c2f89c 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -24,6 +24,10 @@ import OnboardingResumeBanner, { } from "./components/onboarding/OnboardingResumeBanner"; import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; import useAuth from "./hooks/useAuth"; +import { + startTokenRefreshScheduler, + stopTokenRefreshScheduler, +} from "./utils/refreshScheduler"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; import MySignaturePage from "./pages/MySignaturePage"; @@ -212,7 +216,20 @@ const sidebarItems: SidebarItem[] = [ const App = () => { const navigate = useNavigate(); const location = useLocation(); - const { user, company, companyType, createProfileAndSwitch } = useAuth(); + const { user, company, companyType, createProfileAndSwitch, isAuthenticated } = + useAuth(); + + // Keep the server session alive while a user is logged in. Runs after + // login, signup, and page-reload bootstrap alike. + useEffect(() => { + if (!isAuthenticated) { + stopTokenRefreshScheduler(); + return; + } + + startTokenRefreshScheduler(); + return stopTokenRefreshScheduler; + }, [isAuthenticated]); const displayName = user?.name?.en || user?.username || user?.email || "User"; const userEmail = user?.email; diff --git a/apps/edr-freight-web/portal/src/utils/api.ts b/apps/edr-freight-web/portal/src/utils/api.ts index 289709906..6cc569477 100644 --- a/apps/edr-freight-web/portal/src/utils/api.ts +++ b/apps/edr-freight-web/portal/src/utils/api.ts @@ -42,21 +42,41 @@ client.interceptors.request.use((config) => { }); // Token refresh state -let isRefreshing = false; -let failedQueue: { - resolve: (token: string) => void; - reject: (error: unknown) => void; -}[] = []; +let refreshPromise: Promise | null = null; -function processQueue(error: unknown, token?: string) { - failedQueue.forEach(({ resolve, reject }) => { - if (error) { - reject(error); - } else { - resolve(token!); +/** + * Single-flight token refresh: concurrent callers (the 401 interceptor and + * the proactive scheduler) share one in-flight request so the refresh token + * is only rotated once. Throws if no refresh token is stored or the server + * rejects it — callers decide how to end the session. + */ +async function refreshSessionTokens(): Promise { + refreshPromise ??= (async () => { + const refreshToken = getCookie("refresh-token"); + if (!refreshToken) { + throw new Error("missing refresh token"); } + + type TokenPair = { token: string; refreshToken: string }; + const { data } = await client.post & { data?: TokenPair }>( + URL_CONSTANTS.AUTH.REFRESH_TOKEN, + { refreshToken }, + ); + // The API returns the pair flat ({ success, token, refreshToken }); accept + // a { data: { ... } }-wrapped shape too so a transform change can't + // silently break refresh again. + const payload = data.data ?? data; + if (!payload.token || !payload.refreshToken) { + throw new Error("malformed refresh-token response"); + } + setCookie("auth-token", payload.token, 7); + setCookie("refresh-token", payload.refreshToken, 7); + return payload.token; + })().finally(() => { + refreshPromise = null; }); - failedQueue = []; + + return refreshPromise; } // Handle auth errors globally with token refresh @@ -72,54 +92,41 @@ client.interceptors.response.use( // - status is not 401 // - already retried // - it's the refresh endpoint itself + // - it's a credential endpoint (401 there = wrong credentials, not an + // expired session — refreshing would mask the real error) if ( !error.response || error.response.status !== 401 || originalRequest._retry || - originalRequest.url === URL_CONSTANTS.AUTH.REFRESH_TOKEN + originalRequest.url === URL_CONSTANTS.AUTH.REFRESH_TOKEN || + originalRequest.url === URL_CONSTANTS.AUTH.LOGIN || + originalRequest.url === URL_CONSTANTS.USERS.SIGN_UP ) { return Promise.reject(error); } - if (isRefreshing) { - return new Promise((resolve, reject) => { - failedQueue.push({ resolve, reject }); - }).then((token) => { - originalRequest.headers.Authorization = `Bearer ${token}`; - return client(originalRequest); - }); - } - - originalRequest._retry = true; - isRefreshing = true; - - const refreshToken = getCookie("refresh-token"); - - if (!refreshToken) { - isRefreshing = false; - clearAuthCookies(); + // Nothing to refresh with (e.g. not logged in yet) — surface the + // original error instead of a confusing refresh failure. + if (!getCookie("refresh-token")) { + if (getCookie("auth-token")) { + // Half-broken cookie state; reset it. + clearAuthCookies(); + } return Promise.reject(error); } + originalRequest._retry = true; + try { - const { data } = await client.post<{ - data: { token: string; refreshToken: string }; - }>(URL_CONSTANTS.AUTH.REFRESH_TOKEN, { refreshToken }); - const { token, refreshToken: newRefreshToken } = data.data; - setCookie("auth-token", token, 7); - setCookie("refresh-token", newRefreshToken, 7); + const token = await refreshSessionTokens(); originalRequest.headers.Authorization = `Bearer ${token}`; - processQueue(null, token); return client(originalRequest); } catch (refreshError) { - processQueue(refreshError, undefined); clearAuthCookies(); return Promise.reject(refreshError); - } finally { - isRefreshing = false; } }, ); -export { client }; +export { client, clearAuthCookies, getCookie, refreshSessionTokens }; export type { UseQueryOptions, QueryObserverOptions }; diff --git a/apps/edr-freight-web/portal/src/utils/refreshScheduler.ts b/apps/edr-freight-web/portal/src/utils/refreshScheduler.ts new file mode 100644 index 000000000..dceca856e --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/refreshScheduler.ts @@ -0,0 +1,81 @@ +import { isAxiosError } from "axios"; + +import { + clearAuthCookies, + getCookie, + refreshSessionTokens, +} from "./api"; + +/** + * Proactively refreshes the token pair on a fixed cadence so the server-side + * session (a sliding 1-hour window, extended only by /auth/refresh-token) is + * kept alive while the app is open. The 401 interceptor in api.ts remains + * the reactive fallback; both share the same single-flight refresh call. + * + * The interval MUST stay well under the server session window (60 min). + */ +const DEFAULT_INTERVAL_MINUTES = 10; + +const getIntervalMs = () => { + const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES); + return ( + (Number.isFinite(minutes) && minutes > 0 + ? minutes + : DEFAULT_INTERVAL_MINUTES) * 60_000 + ); +}; + +let timerId: number | null = null; +let lastRefreshAt = 0; + +const refreshNow = async () => { + if (!getCookie("refresh-token")) { + // Logged out elsewhere; nothing to keep alive. + stopTokenRefreshScheduler(); + return; + } + + try { + await refreshSessionTokens(); + lastRefreshAt = Date.now(); + } catch (error) { + // Network hiccups are retried on the next tick; only an explicit server + // rejection means the session is dead. + if (isAxiosError(error) && error.response) { + stopTokenRefreshScheduler(); + clearAuthCookies(); + window.location.replace("/login"); + } + } +}; + +/** + * Browsers freeze timers in background tabs — a tab waking up past its + * refresh deadline refreshes immediately instead of waiting a full interval. + */ +const onVisibilityChange = () => { + if (document.visibilityState !== "visible") return; + if (Date.now() - lastRefreshAt >= getIntervalMs()) { + void refreshNow(); + } +}; + +export const startTokenRefreshScheduler = () => { + stopTokenRefreshScheduler(); + + // Token age is unknown here (fresh login vs. hours-old page reload), so + // refresh right away to extend the session window from "now". + lastRefreshAt = 0; + void refreshNow(); + + timerId = window.setInterval(() => void refreshNow(), getIntervalMs()); + document.addEventListener("visibilitychange", onVisibilityChange); +}; + +export const stopTokenRefreshScheduler = () => { + if (timerId !== null) { + window.clearInterval(timerId); + timerId = null; + } + document.removeEventListener("visibilitychange", onVisibilityChange); +}; diff --git a/apps/edr-freight-web/portal/src/utils/result.ts b/apps/edr-freight-web/portal/src/utils/result.ts index 3e9627445..54104442c 100644 --- a/apps/edr-freight-web/portal/src/utils/result.ts +++ b/apps/edr-freight-web/portal/src/utils/result.ts @@ -8,6 +8,32 @@ export type ApiError = { statusCode?: number; }; +/** + * Backend errors arrive as snake_case i18n-style codes (e.g. + * "unable_to_log_in"). Map the known ones to friendly copy and prettify + * anything else so raw codes never reach the UI. `code` stays raw for + * programmatic checks. + */ +const API_ERROR_MESSAGES: Record = { + unable_to_log_in: "Incorrect email or password.", + invalid_refresh_token: "Your session has expired. Please sign in again.", + session_expired: "Your session has expired. Please sign in again.", + session_not_found: "Your session has expired. Please sign in again.", + user_not_found: "No account found for these credentials.", +}; + +const SNAKE_CASE_CODE = /^[a-z0-9]+(?:_[a-z0-9]+)+$/; + +function humanizeApiMessage(raw: string): string { + const known = API_ERROR_MESSAGES[raw]; + if (known) return known; + if (SNAKE_CASE_CODE.test(raw)) { + const text = raw.replaceAll("_", " "); + return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`; + } + return raw; +} + export function extractApiError(err: unknown): ApiError { if (err && typeof err === "object") { const obj = err as Record; @@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError { const statusCode = response.status as number | undefined; const data = response.data as Record | undefined; return { - code: (data?.error as string) || (data?.message as string) || "api_error", - message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred", + code: (data?.message as string) || (data?.error as string) || "api_error", + message: humanizeApiMessage( + (data?.message as string) || + (data?.error as string) || + "An unexpected error occurred", + ), statusCode, }; } From 2cae451edcd39c3b2fa7fc87ed4a367162a5853c Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 8 Jul 2026 08:06:50 +0000 Subject: [PATCH 02/10] feat: implemented the changes request to the company profile --- ...000000000000-CreateCompanyChangeRequest.ts | 60 ++++ .../2000000000001-AddCompanyProfileReview.ts | 28 ++ .../modules/companies/companies.controller.ts | 94 ++++- .../src/modules/companies/companies.module.ts | 11 +- .../modules/companies/companies.service.ts | 320 ++++++++++++++++-- .../company-change-request.repository.ts | 55 +++ .../dto/change-request-response.dto.ts | 40 +++ .../dto/company-info-response.dto.ts | 31 +- .../companies/dto/profile-response.dto.ts | 34 +- .../dto/reject-change-request.dto.ts | 11 + .../companies/dto/response-company.dto.ts | 3 + .../dto/update-company-profile-status.dto.ts | 11 +- .../entities/company-change-request.entity.ts | 70 ++++ .../entities/company-profile.entity.ts | 12 + 14 files changed, 742 insertions(+), 38 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2000000000000-CreateCompanyChangeRequest.ts create mode 100644 apps/edr-freight-api/src/migrations/2000000000001-AddCompanyProfileReview.ts create mode 100644 apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/reject-change-request.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts diff --git a/apps/edr-freight-api/src/migrations/2000000000000-CreateCompanyChangeRequest.ts b/apps/edr-freight-api/src/migrations/2000000000000-CreateCompanyChangeRequest.ts new file mode 100644 index 000000000..9d8d2b5c9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2000000000000-CreateCompanyChangeRequest.ts @@ -0,0 +1,60 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * Staging table for customer profile edits that require backoffice review. An + * already-approved company's settings edits are snapshotted here (Pending) + * instead of being written to the live `companies` row; a reviewer approves + * (snapshot applied) or rejects with a note (customer amends & resubmits). + */ +export class CreateCompanyChangeRequest2000000000000 + implements MigrationInterface +{ + name = 'CreateCompanyChangeRequest2000000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'company_change_request', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'company_id', type: 'uuid' }, + { name: 'snapshot', type: 'jsonb' }, + { name: 'documents', type: 'jsonb', isNullable: true }, + { name: 'status', type: 'varchar', length: '20', default: "'pending'" }, + { name: 'note', type: 'text', isNullable: true }, + { name: 'submitted_by', type: 'uuid', isNullable: true }, + { name: 'submitted_at', type: 'timestamptz', isNullable: true }, + { name: 'reviewed_by', type: 'uuid', isNullable: true }, + { name: 'reviewed_at', type: 'timestamptz', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['company_id'], + referencedSchema: 'freight', + referencedTableName: 'companies', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.company_change_request', + new TableIndex({ name: 'idx_company_change_request_company', columnNames: ['company_id'] }), + ); + await queryRunner.createIndex( + 'freight.company_change_request', + new TableIndex({ name: 'idx_company_change_request_status', columnNames: ['status'] }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.company_change_request', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/2000000000001-AddCompanyProfileReview.ts b/apps/edr-freight-api/src/migrations/2000000000001-AddCompanyProfileReview.ts new file mode 100644 index 000000000..2c2fdf3e1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2000000000001-AddCompanyProfileReview.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Adds reviewer note/id/timestamp to company_profiles so a rejected operational + * role (new ProfileStatus 'rejected') can carry the reason back to the customer, + * who can then amend and reapply. + */ +export class AddCompanyProfileReview2000000000001 + implements MigrationInterface +{ + name = 'AddCompanyProfileReview2000000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns('freight.company_profiles', [ + new TableColumn({ name: 'review_note', type: 'text', isNullable: true }), + new TableColumn({ name: 'reviewed_by', type: 'uuid', isNullable: true }), + new TableColumn({ name: 'reviewed_at', type: 'timestamptz', isNullable: true }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumns('freight.company_profiles', [ + 'review_note', + 'reviewed_by', + 'reviewed_at', + ]); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index b1761b35f..661a204d8 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -43,6 +43,8 @@ import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto"; import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto"; +import { RejectChangeRequestDto } from "./dto/reject-change-request.dto"; +import { ChangeRequestResponseDto } from "./dto/change-request-response.dto"; import { FetchETradeDto } from "./dto/fetch-etrade.dto"; import { ETradeResponseDto } from "./dto/etrade-response.dto"; @@ -68,7 +70,10 @@ export class CompaniesController { ): Promise { const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); - return new CompanyInfoResponseDto(profile, company); + const review = await this.companiesService.getOpenChangeRequestForCompany( + company.id, + ); + return new CompanyInfoResponseDto(profile, company, review); } @Get("profile") @@ -78,7 +83,40 @@ export class CompaniesController { ): Promise { const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); - return new ProfileResponseDto(profile, company); + const review = await this.companiesService.getOpenChangeRequestForCompany( + company.id, + ); + return new ProfileResponseDto(profile, company, review); + } + + @Get("profile/change-request") + @ApiOperation({ + summary: "Current user's open profile change request (pending/rejected)", + }) + async getMyChangeRequest( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { company } = + await this.companiesService.getCompanyInfoByUserId(user.id); + const review = await this.companiesService.getOpenChangeRequestForCompany( + company.id, + ); + return review ? new ChangeRequestResponseDto(review) : null; + } + + @Post("company-profiles/:profileId/reapply") + @ApiOperation({ + summary: "Resubmit a rejected operational role for approval (→ pending)", + }) + async reapplyCompanyProfile( + @CurrentUser() user: CurrentIamUser, + @Param("profileId", ParseUUIDPipe) profileId: string, + ): Promise { + const profile = await this.companiesService.reapplyCompanyProfile( + user.id, + profileId, + ); + return new ResponseCompanyProfileDto(profile); } @Get("dashboard") @@ -354,26 +392,76 @@ export class CompaniesController { @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "Upload documents for a company (onboarding)" }) async uploadDocuments( + @CurrentUser() user: CurrentIamUser, @Param("companyId", ParseUUIDPipe) companyId: string, @UploadedFiles() files: Array, ) { - return this.filesService.uploadMany(companyId, "companies", files); + // Routed through the service so an approved company's uploads are staged for + // review (and lock the customer), while onboarding uploads pass straight through. + return this.companiesService.uploadCompanyDocuments(companyId, files, user.id); } @Patch("company-profiles/:profileId/status") @FreightAdmin() @ApiOperation({ summary: "Update a company profile's approval status" }) async updateCompanyProfileStatus( + @CurrentUser() user: CurrentIamUser, @Param("profileId", ParseUUIDPipe) profileId: string, @Body() dto: UpdateCompanyProfileStatusDto, ): Promise { const profile = await this.companiesService.setCompanyProfileStatus( profileId, dto.status, + dto.note, + user.id, ); return new ResponseCompanyProfileDto(profile); } + @Get(":companyId/change-requests") + @FreightAdmin() + @ApiOperation({ summary: "List a company's profile change requests" }) + async listChangeRequests( + @Param("companyId", ParseUUIDPipe) companyId: string, + ): Promise { + const requests = await this.companiesService.listChangeRequests(companyId); + return requests.map((r) => new ChangeRequestResponseDto(r)); + } + + @Post("change-requests/:id/approve") + @FreightAdmin() + @ApiOperation({ + summary: "Approve a pending profile change request (applies the changes)", + }) + async approveChangeRequest( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + ): Promise { + const request = await this.companiesService.approveChangeRequest( + id, + user.id, + ); + return new ChangeRequestResponseDto(request); + } + + @Post("change-requests/:id/reject") + @FreightAdmin() + @ApiOperation({ + summary: "Reject a pending profile change request with a note", + }) + async rejectChangeRequest( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: RejectChangeRequestDto, + ): Promise { + const request = await this.companiesService.rejectChangeRequest( + id, + dto.note, + user.id, + ); + return new ChangeRequestResponseDto(request); + } + @Post(":companyId/profiles") @FreightAdmin() @ApiOperation({ summary: "Add a profile (employee) to a company" }) diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 42186dd8e..646d01d47 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -12,13 +12,21 @@ import { CompanyDashboardRepository } from "./company-dashboard.repository"; import { Company } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; import { CompanyProfile } from "./entities/company-profile.entity"; +import { CompanyChangeRequest } from "./entities/company-change-request.entity"; import { Booking } from "../bookings/entities/booking.entity"; import { CompanyProfileRepository } from "./company-profile.repository"; +import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { ETradeService } from "./services/etrade.service"; @Module({ imports: [ - TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]), + TypeOrmModule.forFeature([ + Company, + ExternalProfile, + CompanyProfile, + CompanyChangeRequest, + Booking, + ]), HttpModule, FilesModule, FileUploadSettingsModule, @@ -30,6 +38,7 @@ import { ETradeService } from "./services/etrade.service"; CompaniesRepository, ExternalProfileRepository, CompanyProfileRepository, + CompanyChangeRequestRepository, CompanyDashboardRepository, ETradeService, ], diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index fe679627b..559a89a38 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -7,6 +7,7 @@ import { } from "@nestjs/common"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; +import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { ExternalProfileRepository } from "./external-profile.repository"; import { CompanyDashboardRepository, @@ -14,6 +15,7 @@ import { } from "./company-dashboard.repository"; import { MinioService } from "../minio/minio.service"; import { FilesService } from "../files/files.service"; +import { FileRecord } from "../files/entities/file.entity"; import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; import { ETradeService } from "./services/etrade.service"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; @@ -40,6 +42,10 @@ import { ProfileType, ProfileStatus, } from "./entities/company-profile.entity"; +import { + ChangeRequestStatus, + CompanyChangeRequest, +} from "./entities/company-change-request.entity"; export interface UserIdentity { userId: string; @@ -54,6 +60,7 @@ export class CompaniesService { constructor( private readonly companiesRepo: CompaniesRepository, private readonly companyProfilesRepo: CompanyProfileRepository, + private readonly changeRequestRepo: CompanyChangeRequestRepository, private readonly profilesRepo: ExternalProfileRepository, private readonly dashboardRepo: CompanyDashboardRepository, private readonly minioService: MinioService, @@ -574,12 +581,24 @@ export class CompaniesService { return updated; } - async updateProfile( - userId: string, - dto: UpdateProfileDto, - ): Promise { - const { profile, company } = await this.getCompanyInfoByUserId(userId); + /** Keep only the keys that were actually provided (drop `undefined`). */ + private pickDefined(dto: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(dto)) { + if (v !== undefined) out[k] = v; + } + return out; + } + /** + * Translate an UpdateProfileDto (or a staged change-request snapshot) into a + * `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/ + * PoA live there). Pure — the caller runs the async TIN-uniqueness check. + */ + private mapProfileDtoToCompanyUpdates( + company: Company, + dto: Partial, + ): Record { const companyUpdates: Record = {}; const attrUpdates: Record = { ...(company.attributes ?? {}) }; @@ -593,21 +612,10 @@ export class CompaniesService { companyUpdates.country = dto.companyLocation; if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress; - if (dto.tin !== undefined && dto.tin !== company.tin) { - // Reject a TIN already taken by a different company (the user's own draft - // placeholder is fine to overwrite). - const owner = await this.companiesRepo.findByTin(dto.tin); - if (owner && owner.id !== company.id) { - throw new ConflictException( - `This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`, - ); - } + if (dto.tin !== undefined && dto.tin !== company.tin) companyUpdates.tin = dto.tin; - } if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; - if (dto.fanNumber !== undefined) { - companyUpdates.fanNumber = dto.fanNumber; - } + if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber; if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName; @@ -629,8 +637,7 @@ export class CompaniesService { if (dto.poaPhone !== undefined) attrUpdates.poaPhone = normalizeE164(dto.poaPhone); if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail; - if (dto.poaLocation !== undefined) - attrUpdates.poaLocation = dto.poaLocation; + if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation; if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress; if (dto.licenceNumber !== undefined) @@ -653,11 +660,214 @@ export class CompaniesService { companyUpdates.etradePhone = normalizeE164(dto.etradePhone); companyUpdates.attributes = attrUpdates; + return companyUpdates; + } - const updated = await this.companiesRepo.update(company.id, companyUpdates); - if (!updated) - throw new NotFoundException(`Company ${company.id} not found`); - return new ProfileResponseDto(profile, updated); + /** Reject a TIN already registered to a *different* company. */ + private async assertTinAvailable( + company: Company, + tin: string | undefined, + ): Promise { + if (tin === undefined || tin === company.tin) return; + const owner = await this.companiesRepo.findByTin(tin); + if (owner && owner.id !== company.id) { + throw new ConflictException( + `This TIN (${tin}) is already registered to another company. Please check the number and try again.`, + ); + } + } + + /** The company's open (pending or last-rejected) profile change request. */ + async getOpenChangeRequestForCompany( + companyId: string, + ): Promise { + return this.changeRequestRepo.findLatestOpenByCompanyId(companyId); + } + + /** + * Update the current user's profile. + * + * - Company not yet approved (onboarding) → write straight to the Company row, + * as before. The company/role pending→approve gate already covers first-run. + * - Company already `active` → do NOT touch the live Company. Stage the edit in + * a pending change request (merging into any open one) so a backoffice + * reviewer can approve (apply) or reject (with a note). This locks the + * customer until the review resolves. + */ + async updateProfile( + userId: string, + dto: UpdateProfileDto, + ): Promise { + const { profile, company } = await this.getCompanyInfoByUserId(userId); + + if (company.status !== CompanyStatus.Active) { + await this.assertTinAvailable(company, dto.tin); + const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto); + const updated = await this.companiesRepo.update( + company.id, + companyUpdates, + ); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + return new ProfileResponseDto(profile, updated); + } + + // Approved company: stage the change for review, leaving the live row intact. + await this.assertTinAvailable(company, dto.tin); + const fields = this.pickDefined(dto); + + const existing = await this.changeRequestRepo.findPendingByCompanyId( + company.id, + ); + const now = new Date(); + let request: CompanyChangeRequest; + if (existing) { + request = + (await this.changeRequestRepo.update(existing.id, { + snapshot: { ...(existing.snapshot ?? {}), ...fields }, + submittedBy: userId, + submittedAt: now, + note: null, + })) ?? existing; + } else { + request = await this.changeRequestRepo.create({ + companyId: company.id, + snapshot: fields, + status: ChangeRequestStatus.Pending, + submittedBy: userId, + submittedAt: now, + }); + } + + // Live company is unchanged; surface the pending state for the settings page. + return new ProfileResponseDto(profile, company, request); + } + + /** List a company's change requests, newest first (backoffice review). */ + async listChangeRequests( + companyId: string, + ): Promise { + await this.findCompanyById(companyId); + return this.changeRequestRepo.findByCompanyId(companyId); + } + + /** + * Approve a pending change request: apply its snapshot to the live Company and + * mark the request approved. Any staged documents are already attached to the + * company, so nothing else needs promoting. + */ + async approveChangeRequest( + id: string, + reviewerId?: string, + ): Promise { + const request = await this.changeRequestRepo.findById(id); + if (!request) + throw new NotFoundException(`Change request ${id} not found`); + if (request.status !== ChangeRequestStatus.Pending) { + throw new BadRequestException( + `Change request ${id} is already ${request.status}`, + ); + } + + const company = await this.companiesRepo.findById(request.companyId); + if (!company) + throw new NotFoundException(`Company ${request.companyId} not found`); + + const snapshot = (request.snapshot ?? {}) as Partial; + await this.assertTinAvailable(company, snapshot.tin); + const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot); + await this.companiesRepo.update(company.id, companyUpdates); + + return ( + (await this.changeRequestRepo.update(id, { + status: ChangeRequestStatus.Approved, + reviewedBy: reviewerId ?? null, + reviewedAt: new Date(), + note: null, + })) ?? request + ); + } + + /** + * Upload company documents. For an approved company this also opens/updates a + * pending change request (recording the uploaded file ids) so the upload is + * reviewed and the customer is locked until it clears — consistent with the + * field-edit review. During onboarding (company not yet active) it's a plain + * upload with no review. + */ + async uploadCompanyDocuments( + companyId: string, + files: Express.Multer.File[], + submittedBy?: string, + ): Promise { + const company = await this.findCompanyById(companyId); + const uploaded = await this.filesService.uploadMany( + companyId, + "companies", + files, + ); + if (company.status === CompanyStatus.Active) { + await this.stageDocumentChange( + company.id, + uploaded.map((f) => f.id), + submittedBy, + ); + } + return uploaded; + } + + /** Open or append a pending change request recording staged document uploads. */ + private async stageDocumentChange( + companyId: string, + fileIds: string[], + submittedBy?: string, + ): Promise { + if (fileIds.length === 0) return; + const now = new Date(); + const existing = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + if (existing) { + const prev = existing.documents?.documentFileIds ?? []; + await this.changeRequestRepo.update(existing.id, { + documents: { documentFileIds: [...prev, ...fileIds] }, + submittedBy: submittedBy ?? existing.submittedBy ?? null, + submittedAt: now, + note: null, + }); + } else { + await this.changeRequestRepo.create({ + companyId, + snapshot: {}, + documents: { documentFileIds: fileIds }, + status: ChangeRequestStatus.Pending, + submittedBy: submittedBy ?? null, + submittedAt: now, + }); + } + } + + /** Reject a pending change request with a note (customer amends & resubmits). */ + async rejectChangeRequest( + id: string, + note: string, + reviewerId?: string, + ): Promise { + const request = await this.changeRequestRepo.findById(id); + if (!request) + throw new NotFoundException(`Change request ${id} not found`); + if (request.status !== ChangeRequestStatus.Pending) { + throw new BadRequestException( + `Change request ${id} is already ${request.status}`, + ); + } + return ( + (await this.changeRequestRepo.update(id, { + status: ChangeRequestStatus.Rejected, + note, + reviewedBy: reviewerId ?? null, + reviewedAt: new Date(), + })) ?? request + ); } async deleteCompany(id: string): Promise { @@ -713,6 +923,8 @@ export class CompaniesService { async setCompanyProfileStatus( profileId: string, status: ProfileStatus, + note?: string, + reviewerId?: string, ): Promise { const existing = await this.companyProfilesRepo.findById(profileId); if (!existing) @@ -727,6 +939,18 @@ export class CompaniesService { ); } + // Track the review outcome. Rejection keeps the note so the customer knows + // why; approval clears it. Any decision stamps the reviewer + time. + if (status === ProfileStatus.Rejected) { + patch.reviewNote = note ?? null; + } else if (status === ProfileStatus.Active) { + patch.reviewNote = null; + } + if (status !== ProfileStatus.Pending) { + patch.reviewedBy = reviewerId ?? null; + patch.reviewedAt = new Date(); + } + const updated = await this.companyProfilesRepo.update(profileId, patch); if (!updated) throw new NotFoundException(`Company profile ${profileId} not found`); @@ -744,6 +968,41 @@ export class CompaniesService { return updated; } + /** + * Customer reapplies for a rejected operational role (after fixing whatever the + * reviewer flagged, e.g. re-uploading a license): flip it back to Pending and + * clear the rejection note so it re-enters the approval queue. + */ + async reapplyCompanyProfile( + userId: string, + profileId: string, + ): Promise { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + const companyId = profile.company?.id ?? profile.companyId; + + const target = await this.companyProfilesRepo.findById(profileId); + if (!target || target.companyId !== companyId) { + throw new NotFoundException(`Company profile ${profileId} not found`); + } + if (target.status !== ProfileStatus.Rejected) { + throw new BadRequestException( + "Only a rejected role can be resubmitted for approval", + ); + } + + const updated = await this.companyProfilesRepo.update(profileId, { + status: ProfileStatus.Pending, + reviewNote: null, + reviewedBy: null, + reviewedAt: null, + }); + if (!updated) + throw new NotFoundException(`Company profile ${profileId} not found`); + return updated; + } + async createCompanyProfile( companyId: string, profileType?: ProfileType, @@ -833,12 +1092,12 @@ export class CompaniesService { ); if (existing) continue; - const reference = await this.companyProfilesRepo.generateReference(type); + // Self-service role adds start Pending and carry no reference — a reference + // is minted only when a backoffice reviewer approves the role. await this.companyProfilesRepo.create({ companyId, type, - reference, - status: ProfileStatus.Active, + status: ProfileStatus.Pending, }); } @@ -870,13 +1129,14 @@ export class CompaniesService { let created = await this.companyProfilesRepo.findByType(companyId, type); if (!created) { - const reference = await this.companyProfilesRepo.generateReference(type); + // New self-service roles start Pending (awaiting backoffice approval) and + // carry no reference until approved. The customer can select this mode but + // can't book under it until it's cleared. created = await this.companyProfilesRepo.create({ companyId, type, - reference, businessLicense: businessLicense ?? null, - status: ProfileStatus.Active, + status: ProfileStatus.Pending, }); } diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts new file mode 100644 index 000000000..24d988452 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts @@ -0,0 +1,55 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { + ChangeRequestStatus, + CompanyChangeRequest, +} from "./entities/company-change-request.entity"; + +@Injectable() +export class CompanyChangeRequestRepository extends BaseRepository { + constructor( + @InjectRepository(CompanyChangeRequest) + repo: Repository, + ) { + super(repo); + } + + /** The company's current pending request, if any. */ + async findPendingByCompanyId( + companyId: string, + ): Promise { + return this.repository.findOne({ + where: { companyId, status: ChangeRequestStatus.Pending }, + order: { createdAt: "DESC" }, + }); + } + + /** + * The company's latest "open" request — pending (locks the customer) or the + * most recent rejected one (drives the reapply banner + prefill). Approved + * requests are terminal and ignored here. + */ + async findLatestOpenByCompanyId( + companyId: string, + ): Promise { + const pending = await this.findPendingByCompanyId(companyId); + if (pending) return pending; + return this.repository.findOne({ + where: { companyId, status: ChangeRequestStatus.Rejected }, + order: { createdAt: "DESC" }, + }); + } + + async findById(id: string): Promise { + return this.repository.findOne({ where: { id } }); + } + + async findByCompanyId(companyId: string): Promise { + return this.repository.find({ + where: { companyId }, + order: { createdAt: "DESC" }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts new file mode 100644 index 000000000..ddda72a64 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts @@ -0,0 +1,40 @@ +import { + ChangeRequestStatus, + CompanyChangeRequest, +} from "../entities/company-change-request.entity"; + +/** + * A staged profile change request. Used both by the portal (to lock the settings + * page, show the reviewer note, and prefill the proposed values) and by the + * backoffice review screen (to render the proposed-vs-current diff). + */ +export class ChangeRequestResponseDto { + id: string; + companyId: string; + status: ChangeRequestStatus; + /** Proposed field values (Partial) — the diff payload. */ + snapshot: Record; + documentFileIds: string[]; + note: string | null; + submittedBy: string | null; + submittedAt: Date | null; + reviewedBy: string | null; + reviewedAt: Date | null; + createdAt: Date; + updatedAt: Date; + + constructor(req: CompanyChangeRequest) { + this.id = req.id; + this.companyId = req.companyId; + this.status = req.status; + this.snapshot = req.snapshot ?? {}; + this.documentFileIds = req.documents?.documentFileIds ?? []; + this.note = req.note ?? null; + this.submittedBy = req.submittedBy ?? null; + this.submittedAt = req.submittedAt ?? null; + this.reviewedBy = req.reviewedBy ?? null; + this.reviewedAt = req.reviewedAt ?? null; + this.createdAt = req.createdAt; + this.updatedAt = req.updatedAt; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index 04fd42816..9a4fb330a 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -1,14 +1,43 @@ import { Company } from '../entities/company.entity'; import { ExternalProfile } from '../entities/external-profile.entity'; +import { + ChangeRequestStatus, + CompanyChangeRequest, +} from '../entities/company-change-request.entity'; import { ResponseCompanyDto } from './response-company.dto'; import { ResponseExternalProfileDto } from './response-external-profile.dto'; export class CompanyInfoResponseDto { profile: ResponseExternalProfileDto; company: ResponseCompanyDto; + /** + * Open profile-edit review, if any. Drives the portal-wide lock (pending → + * settings + new-contract/booking creation disabled) and the reapply banner. + */ + review: { + status: 'pending' | 'rejected'; + note: string | null; + } | null; - constructor(profile: ExternalProfile, company: Company) { + constructor( + profile: ExternalProfile, + company: Company, + changeRequest?: CompanyChangeRequest | null, + ) { this.profile = new ResponseExternalProfileDto(profile, company); this.company = new ResponseCompanyDto(company); + + const open = + changeRequest && + (changeRequest.status === ChangeRequestStatus.Pending || + changeRequest.status === ChangeRequestStatus.Rejected) + ? changeRequest + : null; + this.review = open + ? { + status: open.status as 'pending' | 'rejected', + note: open.note ?? null, + } + : null; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 89a52b5e6..89ab954e7 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -1,5 +1,9 @@ import { Company } from '../entities/company.entity'; import { ExternalProfile } from '../entities/external-profile.entity'; +import { + ChangeRequestStatus, + CompanyChangeRequest, +} from '../entities/company-change-request.entity'; import { ResponseCompanyProfileDto } from './response-company.dto'; export class ProfileResponseDto { @@ -48,7 +52,20 @@ export class ProfileResponseDto { profileId: string; - constructor(profile: ExternalProfile, company: Company) { + /** + * Open profile-edit review, if any. `reviewStatus === "pending"` locks the + * settings page; `"rejected"` surfaces the note and prefills the (declined) + * proposed values from `pendingChanges` so the customer can amend & resubmit. + */ + reviewStatus: "pending" | "rejected" | null; + reviewNote: string | null; + pendingChanges: Record | null; + + constructor( + profile: ExternalProfile, + company: Company, + changeRequest?: CompanyChangeRequest | null, + ) { this.companyId = company.id; this.companyName = company.name; this.companyType = company.type; @@ -92,5 +109,20 @@ export class ProfileResponseDto { this.poaEmail = attrs.poaEmail ?? null; this.poaLocation = attrs.poaLocation ?? null; this.poaAddress = attrs.poaAddress ?? null; + + const openReview = + changeRequest && + (changeRequest.status === ChangeRequestStatus.Pending || + changeRequest.status === ChangeRequestStatus.Rejected) + ? changeRequest + : null; + this.reviewStatus = + openReview?.status === ChangeRequestStatus.Pending + ? "pending" + : openReview?.status === ChangeRequestStatus.Rejected + ? "rejected" + : null; + this.reviewNote = openReview?.note ?? null; + this.pendingChanges = openReview?.snapshot ?? null; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/reject-change-request.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/reject-change-request.dto.ts new file mode 100644 index 000000000..c32b44a79 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/reject-change-request.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, MaxLength, MinLength } from "class-validator"; + +export class RejectChangeRequestDto { + /** Why the proposed changes were declined — shown to the customer so they can fix and resubmit. */ + @ApiProperty() + @IsString() + @MinLength(1) + @MaxLength(2000) + note!: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 5d90d8d60..f90b7f88d 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -21,6 +21,8 @@ export class ResponseCompanyProfileDto { /** Business-license documents stored on the profile (multi-file). */ licenseFiles: BusinessLicenseFile[]; attributes?: Record | null; + /** Reviewer note when the role is rejected (drives the reapply prompt). */ + reviewNote?: string | null; createdAt: Date; updatedAt: Date; @@ -33,6 +35,7 @@ export class ResponseCompanyProfileDto { this.businessLicense = profile.businessLicense; this.licenseFiles = profile.businessLicenseFiles ?? []; this.attributes = profile.attributes; + this.reviewNote = profile.reviewNote ?? null; this.createdAt = profile.createdAt; this.updatedAt = profile.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts index 96c02d846..83beb441f 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts @@ -1,9 +1,16 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsIn } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsOptional, IsString, MaxLength } from "class-validator"; import { ProfileStatus } from "../entities/company-profile.entity"; export class UpdateCompanyProfileStatusDto { @ApiProperty({ enum: ProfileStatus }) @IsIn(Object.values(ProfileStatus)) status!: ProfileStatus; + + /** Reviewer note — required in practice when rejecting so the customer knows why. */ + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts new file mode 100644 index 000000000..11e5c09cb --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -0,0 +1,70 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import { Company } from "./company.entity"; + +/** + * Lifecycle of a customer's proposed profile change. Edits made on the portal + * settings page by an already-approved company are staged here (not written to + * the live Company row) until a backoffice reviewer approves — at which point + * the snapshot is applied — or rejects with a note, after which the customer can + * amend and resubmit. + */ +export enum ChangeRequestStatus { + Pending = "pending", + Approved = "approved", + Rejected = "rejected", +} + +/** File references staged alongside a change request (documents/licenses). */ +export interface ChangeRequestDocuments { + /** FileRecord ids uploaded against the company while this request was open. */ + documentFileIds?: string[]; +} + +@Entity({ schema: "freight", name: "company_change_request" }) +@Index(["companyId"]) +@Index(["status"]) +export class CompanyChangeRequest extends BaseEntity { + @Column({ name: "company_id", type: "uuid" }) + companyId!: string; + + @ManyToOne(() => Company, { onDelete: "CASCADE" }) + @JoinColumn({ name: "company_id" }) + company?: Company; + + /** + * Proposed profile field values, shaped as `Partial`. Covers + * the Company / Contact / General Manager / Power-of-Attorney tabs (contact/GM/ + * PoA fields land in `Company.attributes` on approval). + */ + @Column({ name: "snapshot", type: "jsonb" }) + snapshot!: Record; + + /** Staged document/license file references (see {@link ChangeRequestDocuments}). */ + @Column({ name: "documents", type: "jsonb", nullable: true }) + documents?: ChangeRequestDocuments | null; + + @Column({ + name: "status", + type: "varchar", + length: 20, + default: ChangeRequestStatus.Pending, + }) + status!: ChangeRequestStatus; + + /** Backoffice reviewer's rejection note. */ + @Column({ name: "note", type: "text", nullable: true }) + note?: string | null; + + @Column({ name: "submitted_by", type: "uuid", nullable: true }) + submittedBy?: string | null; + + @Column({ name: "submitted_at", type: "timestamptz", nullable: true }) + submittedAt?: Date | null; + + @Column({ name: "reviewed_by", type: "uuid", nullable: true }) + reviewedBy?: string | null; + + @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) + reviewedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index e61668a07..77b62a786 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -13,6 +13,8 @@ export enum ProfileType { export enum ProfileStatus { Active = "active", Pending = "pending", + /** Reviewer declined the role; carries a note. Customer can reapply → Pending. */ + Rejected = "rejected", Suspended = "suspended", Blacklisted = "blacklisted", } @@ -80,4 +82,14 @@ export class CompanyProfile extends BaseEntity { @Column({ name: "attributes", type: "jsonb", nullable: true }) attributes?: Record | null; + + /** Reviewer's note when the role is Rejected (cleared on reapply). */ + @Column({ name: "review_note", type: "text", nullable: true }) + reviewNote?: string | null; + + @Column({ name: "reviewed_by", type: "uuid", nullable: true }) + reviewedBy?: string | null; + + @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) + reviewedAt?: Date | null; } From 3bc4514b04bc920a8f266b7f970e28cddcfa5114 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 8 Jul 2026 08:07:10 +0000 Subject: [PATCH 03/10] feat: implemented the changes request to the company profile to backoffice --- .../customers/ChangeRequestReview.tsx | 324 ++++++++++++++++++ .../src/components/customers/badges.tsx | 130 +++++-- .../src/components/customers/index.ts | 4 + .../backoffice/src/constants/QUERY_KEYS.ts | 2 + .../backoffice/src/constants/URLS.ts | 6 + .../pages/customers/CustomerDetailPage.tsx | 5 + .../backoffice/src/services/api.ts | 38 +- .../src/services/customers.service.ts | 37 +- .../backoffice/src/types/customer.ts | 30 +- 9 files changed, 546 insertions(+), 30 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx new file mode 100644 index 000000000..a5de7335a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -0,0 +1,324 @@ +import { + Alert, + Badge, + Box, + Button, + Card, + Group, + Modal, + SimpleGrid, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { AlertTriangle, ClipboardCheck, Clock } from "lucide-react"; +import { useState } from "react"; + +import { api } from "@/services/api"; +import type { Company, CompanyChangeRequest } from "@/types/customer"; +import { formatDate, humanize } from "./format"; + +/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */ +const FIELD_LABELS: Record = { + companyName: "Company name", + companyEmail: "Company email", + companyPhone: "Company phone", + companyLocation: "Location", + companyAddress: "Address", + tin: "TIN", + vatNumber: "VAT number", + fanNumber: "FAN number", + nationality: "Nationality", + licenceNumber: "Licence number", + contactPersonName: "Contact person", + contactPersonPosition: "Contact position", + contactPersonEmail: "Contact email", + contactPersonPhone: "Contact phone", + generalManagerName: "General manager", + generalManagerEmail: "GM email", + generalManagerPhone: "GM phone", + poaName: "PoA name", + poaPhone: "PoA phone", + poaEmail: "PoA email", + poaLocation: "PoA location", + poaAddress: "PoA address", + region: "Region", + zone: "Zone", + woreda: "Woreda", + kebele: "Kebele", + houseNo: "House no.", +}; + +/** Best-effort current value on the live company for a proposed field key. */ +function currentValue(company: Company, key: string): string { + const c = company as unknown as Record; + const attrs = (company.attributes ?? {}) as Record; + const map: Record = { + companyName: c.name, + companyEmail: c.email, + companyPhone: c.phone, + companyLocation: c.country, + companyAddress: c.address, + tin: c.tin, + vatNumber: c.vatNumber, + fanNumber: c.fanNumber, + nationality: c.nationality, + contactPersonName: c.contactPersonName ?? attrs.contactPersonName, + contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone, + generalManagerName: c.generalManagerName ?? attrs.generalManagerName, + generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail, + generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone, + }; + const v = key in map ? map[key] : (c[key] ?? attrs[key]); + return v === null || v === undefined || v === "" ? "—" : String(v); +} + +function DiffRow({ + label, + from, + to, +}: { + label: string; + from: string; + to: string; +}) { + const changed = from !== to; + return ( + + + {label} + + + + {from} + + {changed && ( + <> + + → + + + {to} + + + )} + + + ); +} + +/** + * Backoffice review surface for a customer's staged profile edits. Shows the + * pending change request as a proposed-vs-current diff with Approve / Reject + * (with note) actions, plus a short history of past decisions. + */ +export function ChangeRequestReview({ company }: { company: Company }) { + const query = useQuery( + api.customers.changeRequests.queryOptions({ input: { id: company.id } }), + ); + const approve = useMutation( + api.customers.approveChangeRequest.mutationOptions(), + ); + const reject = useMutation( + api.customers.rejectChangeRequest.mutationOptions(), + ); + + const [rejectId, setRejectId] = useState(null); + const [note, setNote] = useState(""); + + const requests = query.data ?? []; + const pending = requests.find((r) => r.status === "pending"); + const history = requests.filter((r) => r.status !== "pending").slice(0, 5); + + if (!pending && history.length === 0) return null; + + const proposedKeys = pending + ? Object.keys(pending.snapshot ?? {}) + : ([] as string[]); + const docCount = pending?.documentFileIds?.length ?? 0; + + const confirmReject = () => { + if (!rejectId) return; + reject.mutate( + { id: rejectId, note: note.trim() }, + { + onSuccess: () => { + setRejectId(null); + setNote(""); + }, + }, + ); + }; + + return ( + <> + {pending && ( + + + + + + + Profile changes awaiting review + + + Pending + + + + Submitted {formatDate(pending.submittedAt ?? pending.createdAt)} + + + + {proposedKeys.length > 0 ? ( + + {proposedKeys.map((key) => ( + + ))} + + ) : ( + + No field changes — document uploads only. + + )} + + {docCount > 0 && ( + + {docCount} document{docCount === 1 ? "" : "s"} uploaded with this + request — review them in the Documents tab. + + )} + + + + + + + + )} + + {history.length > 0 && ( + + + + Review history + + {history.map((r: CompanyChangeRequest) => ( + + + {r.status} + + + + {formatDate(r.reviewedAt ?? r.updatedAt)} + + {r.note && ( + + Note: {r.note} + + )} + + + ))} + + + )} + + setRejectId(null)} + title="Reject changes" + centered + radius="lg" + > + + }> + The customer will see this note and can amend and resubmit. + +