mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 02:00:56 +00:00
Merge pull request #730 from Tria-plc/freight/feat/fixes-v1
Freight/feat/fixes v1
This commit is contained in:
@@ -376,4 +376,97 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
||||
expect(result).toBeNull();
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("also retires a DRAFT invoice — a superseded/cancelled source must not leave one behind", async () => {
|
||||
const { service, defaultManager } = build({
|
||||
...openInvoice,
|
||||
status: Freight.InvoiceStatus.Draft,
|
||||
});
|
||||
|
||||
await service.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"prepaid",
|
||||
);
|
||||
|
||||
const { where } = defaultManager.findOne.mock.calls[0][1];
|
||||
expect(where.status.value).toContain(Freight.InvoiceStatus.Draft);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.issuePayable", () => {
|
||||
const dueAt = new Date("2026-01-02T00:00:00.000Z");
|
||||
|
||||
const build = (found: Record<string, unknown> | null) => {
|
||||
const manager = {
|
||||
findOne: jest.fn().mockResolvedValue(found),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new BillingService(
|
||||
{ manager, transaction: jest.fn() } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
makeEvents() as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, manager };
|
||||
};
|
||||
|
||||
const issue = (service: BillingService) =>
|
||||
service.issuePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
dueAt,
|
||||
"PREPAID",
|
||||
);
|
||||
|
||||
it("issues a DRAFT invoice to PENDING, stamping issuedAt and the pay-window dueAt", async () => {
|
||||
const { service, manager } = build({
|
||||
id: "inv-1",
|
||||
invoiceNumber: "INV-20260101-00001",
|
||||
status: Freight.InvoiceStatus.Draft,
|
||||
issuedAt: null,
|
||||
});
|
||||
|
||||
const result = await issue(service);
|
||||
|
||||
const patch = manager.update.mock.calls[0][2];
|
||||
expect(patch.status).toBe(Freight.InvoiceStatus.Pending);
|
||||
expect(patch.dueAt).toBe(dueAt);
|
||||
expect(patch.issuedAt).toBeInstanceOf(Date);
|
||||
expect(result?.status).toBe(Freight.InvoiceStatus.Pending);
|
||||
});
|
||||
|
||||
it("looks up DRAFT invoices — a booking's invoice is minted DRAFT and this is what makes it payable", async () => {
|
||||
const { service, manager } = build(null);
|
||||
|
||||
await issue(service);
|
||||
|
||||
const { where } = manager.findOne.mock.calls[0][1];
|
||||
expect(where.status.value).toContain(Freight.InvoiceStatus.Draft);
|
||||
});
|
||||
|
||||
it("only refreshes dueAt on an already-issued invoice, so a re-reserve never re-issues", async () => {
|
||||
const issuedAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
const { service, manager } = build({
|
||||
id: "inv-1",
|
||||
invoiceNumber: "INV-20260101-00001",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
issuedAt,
|
||||
});
|
||||
|
||||
const result = await issue(service);
|
||||
|
||||
expect(manager.update.mock.calls[0][2]).toEqual({ dueAt });
|
||||
expect(result?.issuedAt).toBe(issuedAt);
|
||||
});
|
||||
|
||||
it("is a no-op (returns null, writes nothing) when the source has no draft-or-open invoice", async () => {
|
||||
const { service, manager } = build(null);
|
||||
|
||||
await expect(issue(service)).resolves.toBeNull();
|
||||
expect(manager.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -826,8 +826,15 @@ export class BillingService {
|
||||
* Expire a source's currently-open invoice (its pay window closed before
|
||||
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
|
||||
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
|
||||
* `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice
|
||||
* (already paid/cancelled/expired).
|
||||
* `OPEN_STATUSES`). No-op (returns null) when the source has no invoice left to
|
||||
* retire (already paid/cancelled/expired).
|
||||
*
|
||||
* DRAFT invoices are matched too, even though they were never issued: this is
|
||||
* also the "retire the invoice this source no longer needs" path (a cancelled
|
||||
* booking, or a full-amount invoice superseded by a partial-offer one). Skipping
|
||||
* drafts would leave the stale one behind for `findPayable` to hand back — the
|
||||
* superseding invoice would then never be minted, and a cancelled booking would
|
||||
* keep a draft that a later `issuePayable` could still make payable.
|
||||
*
|
||||
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
|
||||
* the batch engine) to enlist in its DB transaction.
|
||||
@@ -850,7 +857,7 @@ export class BillingService {
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
status: In(OPEN_STATUSES),
|
||||
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
@@ -867,30 +874,58 @@ export class BillingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a source's open invoice `dueAt` to its real pay-window deadline. The
|
||||
* booking invoice is generated before the pay window opens (at booking
|
||||
* creation/approval), so its printed due date is refreshed when the batch engine
|
||||
* sets `paymentDeadline`. No-op when the source has no open invoice.
|
||||
* Issue a source's invoice and stamp its real pay-window deadline — the single
|
||||
* transition that makes a source payable.
|
||||
*
|
||||
* A source's invoice is minted DRAFT, before any pay window exists (e.g. a
|
||||
* booking invoice is generated at creation / operation-accept, long before the
|
||||
* batch engine reserves a slot). DRAFT is deliberately outside `OPEN_STATUSES`,
|
||||
* so such an invoice is not settleable and the portal renders no pay button.
|
||||
* The domain calls this at the moment the pay window actually opens (booking →
|
||||
* `reserve`, which sets SELECTED_FOR_BATCH + `paymentDeadline`), which issues
|
||||
* the draft (→ PENDING, stamping `issuedAt`) and prints the real `dueAt`.
|
||||
*
|
||||
* Idempotent: an already-issued open invoice only has its `dueAt` refreshed, so
|
||||
* a re-reserve never re-issues. No-op (returns null) when the source has no
|
||||
* draft-or-open invoice (already paid/cancelled/expired).
|
||||
*/
|
||||
async syncPayableDueDate(
|
||||
async issuePayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
dueAt: Date,
|
||||
type?: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
status: In(OPEN_STATUSES),
|
||||
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return;
|
||||
await mg.update(Invoice, { id: invoice.id }, { dueAt });
|
||||
if (!invoice) return null;
|
||||
|
||||
const issuing = invoice.status === Freight.InvoiceStatus.Draft;
|
||||
const patch = {
|
||||
dueAt,
|
||||
...(issuing
|
||||
? {
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
issuedAt: invoice.issuedAt ?? new Date(),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await mg.update(Invoice, { id: invoice.id }, patch);
|
||||
|
||||
if (issuing) {
|
||||
this.logger.log(
|
||||
`Issued invoice ${invoice.invoiceNumber} (${invoice.id}) for ${source}:${sourceId} — payable until ${dueAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
return { ...invoice, ...patch } as Invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,8 +33,6 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv
|
||||
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
|
||||
|
||||
import { Freight } from "@edr/types";
|
||||
import { BookingInvoiceService } from "./booking-invoice.service";
|
||||
|
||||
@Injectable()
|
||||
@@ -1112,14 +1110,25 @@ export class BookingTransitionService {
|
||||
await this.bookingBatchService.pickExportSchedule(booking);
|
||||
}
|
||||
|
||||
// Mint the booking's invoice (DRAFT) so the priced order carries its billing
|
||||
// record from accept onward. It is deliberately NOT issued here: accepting an
|
||||
// operation only puts the booking in the batch holding pool — no slot has been
|
||||
// offered and no pay window exists yet. Issuing at this point made the invoice
|
||||
// payable straight away (portal invoice list/detail gate on invoice status
|
||||
// alone), letting a customer pay before being selected for a batch, while the
|
||||
// booking page correctly still showed it as not payable. The batch engine
|
||||
// issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window
|
||||
// and the real deadline are created — matching the portal's `canPay` gate.
|
||||
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
||||
this.logger.log(
|
||||
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
|
||||
);
|
||||
await this.invoiceService.updateStatus(
|
||||
invoice.id,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
|
||||
);
|
||||
// TODO: road (truck) orders are an incomplete feature — they stop at the
|
||||
// dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no
|
||||
// per-km pricing wired via roadKmPrice, no pay surface in the portal). They
|
||||
// skip the train batch, so they never reach `reserve` and their invoice stays
|
||||
// DRAFT / unpayable. When the road flow is built, issue its invoice
|
||||
// (billing.issuePayable) at whatever transition opens the road pay window.
|
||||
if (isRoadService(booking.serviceType)) {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: "ROAD_DISPATCH_PENDING",
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
{
|
||||
syncPayableDueDate: jest.fn().mockResolvedValue(undefined),
|
||||
issuePayable: jest.fn().mockResolvedValue(null),
|
||||
expirePayable: jest.fn().mockResolvedValue(undefined),
|
||||
} as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
@@ -596,7 +596,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
notifier as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||
{ issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||||
undefined,
|
||||
@@ -619,7 +619,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
notifier as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||
{ issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||||
undefined,
|
||||
@@ -650,7 +650,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
notifier as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||
{ issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||||
undefined,
|
||||
|
||||
@@ -2317,9 +2317,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
paymentDeadline: deadline,
|
||||
} as never);
|
||||
booking.trainScheduleId = scheduleId;
|
||||
// The invoice was generated at booking creation/approval, before this pay
|
||||
// window opened — refresh its printed due date to the real deadline.
|
||||
await this.billing.syncPayableDueDate(
|
||||
// The invoice was generated DRAFT at booking creation / operation-accept,
|
||||
// before this pay window existed. Reserving is the moment the booking becomes
|
||||
// payable (SELECTED_FOR_BATCH + a real deadline), so issue the draft here and
|
||||
// print the deadline as its due date — never earlier, or the customer could
|
||||
// settle an invoice for a slot they have not been offered yet. Idempotent: a
|
||||
// re-reserve only refreshes `dueAt`.
|
||||
await this.billing.issuePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
booking.id,
|
||||
deadline,
|
||||
|
||||
@@ -4,3 +4,8 @@ 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
|
||||
|
||||
# PostHog — session replay, error tracking, console logs. Both must be set or
|
||||
# observability stays off (the app works either way). Self-hosted instance.
|
||||
VITE_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
VITE_POSTHOG_HOST=https://posthog.example.com
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@mantine/core": "^9.3.0",
|
||||
"@mantine/dates": "^9.3.0",
|
||||
"@mantine/hooks": "^9.3.0",
|
||||
"@posthog/react": "^1.10.3",
|
||||
"@radix-ui/react-accordion": "^1.2.13",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.16",
|
||||
"@radix-ui/react-avatar": "^1.1.12",
|
||||
@@ -76,6 +77,7 @@
|
||||
"lucide-react": "^1.14.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"posthog-js": "^1.400.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"qs": "^6.15.2",
|
||||
"radix-ui": "^1.4.3",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { useIdentify } from "@/lib/posthog";
|
||||
import { getMeRequest, loginRequest, verifyMfaRequest } from "./api";
|
||||
import {
|
||||
AUTH_TOKEN_COOKIE,
|
||||
@@ -69,6 +70,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const mfaEmailRef = useRef<string | null>(null);
|
||||
|
||||
// Attribute replays and exceptions to the signed-in user (id/org only).
|
||||
useIdentify(user);
|
||||
|
||||
const loadCurrentUser = async () => {
|
||||
const currentUser = await getMeRequest();
|
||||
setUser(currentUser);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
emitApiError,
|
||||
extractApiErrorPayload,
|
||||
} from "@/components/errors/ApiErrorModal";
|
||||
import { captureApiError } from "@/lib/posthog";
|
||||
import {
|
||||
AUTH_TOKEN_COOKIE,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
@@ -82,6 +83,14 @@ 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.
|
||||
// 401s are skipped: an expired session is refreshed below, not a defect.
|
||||
if (!error.response || error.response.status !== 401) {
|
||||
captureApiError(error);
|
||||
}
|
||||
|
||||
if (
|
||||
error.response?.status !== 401 ||
|
||||
!originalRequest ||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
import { captureException } from "@/lib/posthog";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -21,6 +23,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
captureException(error, { componentStack: info.componentStack });
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[ErrorBoundary] Uncaught render error:", error, info.componentStack);
|
||||
}
|
||||
|
||||
140
apps/edr-freight-web/backoffice/src/lib/posthog.ts
Normal file
140
apps/edr-freight-web/backoffice/src/lib/posthog.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* PostHog wiring — session replay, exception capture, console logs.
|
||||
*
|
||||
* NOTE: `portal/src/lib/posthog.ts` is the twin of this file. The init config
|
||||
* below (masking rules) and the PII allowlist in `useIdentify` MUST be kept
|
||||
* identical in both — a change made here alone silently leaks staff data into
|
||||
* the other app's replays.
|
||||
*
|
||||
* This is instrumentation, not analytics: autocapture is off and no product
|
||||
* events are sent.
|
||||
*/
|
||||
import posthog from "posthog-js";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { AuthUser } from "@/auth/types";
|
||||
|
||||
const APP = "freight-backoffice";
|
||||
|
||||
const TOKEN = import.meta.env.VITE_POSTHOG_KEY;
|
||||
const HOST = import.meta.env.VITE_POSTHOG_HOST;
|
||||
|
||||
/**
|
||||
* Whether init actually ran. Without a token every export below is a no-op, so
|
||||
* local dev and any environment whose env file lacks the vars keeps working —
|
||||
* a missing observability token must never break the app.
|
||||
*/
|
||||
let enabled = false;
|
||||
|
||||
export function initPostHog(): void {
|
||||
if (enabled || !TOKEN || !HOST) return;
|
||||
|
||||
posthog.init(TOKEN, {
|
||||
api_host: HOST,
|
||||
defaults: "2026-05-30",
|
||||
|
||||
// Debuggability, not analytics.
|
||||
autocapture: false,
|
||||
capture_pageview: true,
|
||||
capture_pageleave: true,
|
||||
disable_surveys: true,
|
||||
person_profiles: "identified_only",
|
||||
|
||||
capture_exceptions: {
|
||||
capture_unhandled_errors: true,
|
||||
capture_unhandled_rejections: true,
|
||||
capture_console_errors: true,
|
||||
},
|
||||
|
||||
// Off by default; this is half the point of the integration.
|
||||
enable_recording_console_log: true,
|
||||
|
||||
// Inputs are masked; rendered text stays visible so replays are readable.
|
||||
// Wrap sensitive elements in `ph-no-capture` to blank them individually.
|
||||
session_recording: { maskAllInputs: true },
|
||||
});
|
||||
|
||||
// Portal and backoffice share one PostHog project — filter by this.
|
||||
posthog.register({ app: APP });
|
||||
|
||||
enabled = true;
|
||||
}
|
||||
|
||||
export function captureException(
|
||||
error: unknown,
|
||||
properties?: Record<string, unknown>,
|
||||
): void {
|
||||
if (!enabled) return;
|
||||
posthog.captureException(error, properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a failed API call.
|
||||
*
|
||||
* Called from the axios interceptor rather than from `emitApiError`, which
|
||||
* early-returns on suppressed paths (warehouse / first-mile / onboarding /
|
||||
* auth) — hooking there would drop errors on exactly those pages.
|
||||
*
|
||||
* 5xx and network failures are real defects and go to Error tracking. 4xx is
|
||||
* usually the server correctly rejecting input, so it is recorded as a plain
|
||||
* event to keep the issue list signal-heavy.
|
||||
*/
|
||||
export function captureApiError(error: unknown): void {
|
||||
if (!enabled) return;
|
||||
|
||||
const err = error as {
|
||||
message?: string;
|
||||
config?: { method?: string; url?: string };
|
||||
response?: { status?: number };
|
||||
};
|
||||
|
||||
const status = err.response?.status;
|
||||
const properties = {
|
||||
api_status: status ?? null,
|
||||
api_method: err.config?.method?.toUpperCase() ?? null,
|
||||
api_path: err.config?.url ?? null,
|
||||
};
|
||||
|
||||
if (status && status < 500) {
|
||||
posthog.capture("api_error", properties);
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.captureException(error, properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the current user to PostHog so replays and exceptions are
|
||||
* attributable.
|
||||
*
|
||||
* Deliberately sends NO contact details. `AuthUser` carries email, phoneNumber,
|
||||
* name and username; these are railway staff, and debugging a replay never
|
||||
* requires knowing how to phone the person in it.
|
||||
*/
|
||||
export function useIdentify(user: AuthUser | null): void {
|
||||
const employee = user?.employee?.[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
if (!user?.id) {
|
||||
posthog.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.identify(user.id, {
|
||||
roles: user.roles?.map((role) => role.key ?? role.id),
|
||||
status: user.status,
|
||||
is_super_admin: user.isSuperAdmin,
|
||||
organization_id: employee?.organizationId,
|
||||
unit_id: employee?.unitId,
|
||||
});
|
||||
}, [
|
||||
user?.id,
|
||||
user?.roles,
|
||||
user?.status,
|
||||
user?.isSuperAdmin,
|
||||
employee?.organizationId,
|
||||
employee?.unitId,
|
||||
]);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import posthog from "posthog-js";
|
||||
import { PostHogProvider } from "@posthog/react";
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/dates/styles.css";
|
||||
import "@edr/ui-common/styles.css";
|
||||
@@ -19,6 +21,7 @@ import { ApiErrorModal } from "./components/errors/ApiErrorModal";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { AuthProvider } from "./auth/AuthProvider";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import { initPostHog } from "./lib/posthog";
|
||||
import { freightMantineTheme } from "./theme/freight-brand";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
@@ -43,6 +46,10 @@ const applyStoredTheme = () => {
|
||||
|
||||
applyStoredTheme();
|
||||
|
||||
// Must run before render so replay and exception capture cover startup errors.
|
||||
// No-ops when VITE_POSTHOG_KEY is unset.
|
||||
initPostHog();
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
if (!rootElement) {
|
||||
@@ -50,23 +57,25 @@ if (!rootElement) {
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider theme={freightMantineTheme}>
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
{/* Global API error modal — shows the server's actual error
|
||||
message (suppressed on warehouse / mile / onboarding pages). */}
|
||||
<ApiErrorModal />
|
||||
<Toaster position="top-right" />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
<PostHogProvider client={posthog}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider theme={freightMantineTheme}>
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
{/* Global API error modal — shows the server's actual error
|
||||
message (suppressed on warehouse / mile / onboarding pages). */}
|
||||
<ApiErrorModal />
|
||||
<Toaster position="top-right" />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
</PostHogProvider>
|
||||
);
|
||||
|
||||
// run
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from "react";
|
||||
|
||||
import { captureException } from "@/lib/posthog";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -20,7 +22,8 @@ export class ErrorBoundary extends Component<
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// Log to console in development; replace with a reporting service (e.g. Sentry) in production
|
||||
captureException(error, { componentStack: info.componentStack });
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("ErrorBoundary caught:", error, info.componentStack);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ interface Window {
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_BASE_API_URL: string;
|
||||
readonly VITE_TOKEN_REFRESH_INTERVAL_MINUTES?: string;
|
||||
/** PostHog project token. Absent = observability disabled (see lib/posthog.ts). */
|
||||
readonly VITE_POSTHOG_KEY?: string;
|
||||
/** Self-hosted PostHog instance URL. */
|
||||
readonly VITE_POSTHOG_HOST?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@mantine/core": "^9.3.0",
|
||||
"@mantine/dates": "^9.3.0",
|
||||
"@mantine/hooks": "^9.3.0",
|
||||
"@posthog/react": "^1.10.3",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
|
||||
"@vis.gl/react-google-maps": "^1.8.3",
|
||||
@@ -26,6 +27,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"posthog-js": "^1.400.1",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
|
||||
@@ -25,6 +25,7 @@ import OnboardingResumeBanner, {
|
||||
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
|
||||
import { ApiErrorModal } from "./components/errors/ApiErrorModal";
|
||||
import useAuth from "./hooks/useAuth";
|
||||
import { useIdentify } from "./lib/posthog";
|
||||
import {
|
||||
startTokenRefreshScheduler,
|
||||
stopTokenRefreshScheduler,
|
||||
@@ -221,6 +222,9 @@ const App = () => {
|
||||
const { user, company, companyType, createProfile, isAuthenticated } =
|
||||
useAuth();
|
||||
|
||||
// Attribute replays and exceptions to the signed-in user (id/org only).
|
||||
useIdentify(user, company);
|
||||
|
||||
// Keep the server session alive while a user is logged in. Runs after
|
||||
// login, signup, and page-reload bootstrap alike.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Shown when a render error escapes to the app root.
|
||||
*
|
||||
* Before this existed, a render exception unmounted the tree and left the
|
||||
* customer staring at a blank white page with no way forward. The matching
|
||||
* `$exception` is reported by the surrounding PostHogErrorBoundary.
|
||||
*/
|
||||
export function AppErrorFallback() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50 p-6">
|
||||
<div className="w-full max-w-md rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
This page failed to load. The problem has been reported. Try reloading
|
||||
— if it keeps happening, please contact support.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 rounded-lg bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800"
|
||||
>
|
||||
Reload page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppErrorFallback;
|
||||
151
apps/edr-freight-web/portal/src/lib/posthog.ts
Normal file
151
apps/edr-freight-web/portal/src/lib/posthog.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* PostHog wiring — session replay, exception capture, console logs.
|
||||
*
|
||||
* NOTE: `backoffice/src/lib/posthog.ts` is the twin of this file. The init
|
||||
* config below (masking rules) and the PII allowlist in `useIdentify` MUST be
|
||||
* kept identical in both — a change made here alone silently leaks customer
|
||||
* data into the other app's replays.
|
||||
*
|
||||
* This is instrumentation, not analytics: autocapture is off and no product
|
||||
* events are sent.
|
||||
*/
|
||||
import posthog from "posthog-js";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
|
||||
const APP = "freight-portal";
|
||||
|
||||
const TOKEN = import.meta.env.VITE_POSTHOG_KEY;
|
||||
const HOST = import.meta.env.VITE_POSTHOG_HOST;
|
||||
|
||||
/**
|
||||
* Whether init actually ran. Without a token every export below is a no-op, so
|
||||
* local dev and any environment whose env file lacks the vars keeps working —
|
||||
* a missing observability token must never break the app.
|
||||
*/
|
||||
let enabled = false;
|
||||
|
||||
export function initPostHog(): void {
|
||||
if (enabled || !TOKEN || !HOST) return;
|
||||
|
||||
posthog.init(TOKEN, {
|
||||
api_host: HOST,
|
||||
defaults: "2026-05-30",
|
||||
|
||||
// Debuggability, not analytics.
|
||||
autocapture: false,
|
||||
capture_pageview: true,
|
||||
capture_pageleave: true,
|
||||
disable_surveys: true,
|
||||
person_profiles: "identified_only",
|
||||
|
||||
capture_exceptions: {
|
||||
capture_unhandled_errors: true,
|
||||
capture_unhandled_rejections: true,
|
||||
capture_console_errors: true,
|
||||
},
|
||||
|
||||
// Off by default; this is half the point of the integration.
|
||||
enable_recording_console_log: true,
|
||||
|
||||
// Inputs are masked; rendered text stays visible so replays are readable.
|
||||
// Wrap sensitive elements in `ph-no-capture` to blank them individually.
|
||||
session_recording: { maskAllInputs: true },
|
||||
});
|
||||
|
||||
// Portal and backoffice share one PostHog project — filter by this.
|
||||
posthog.register({ app: APP });
|
||||
|
||||
enabled = true;
|
||||
}
|
||||
|
||||
export function captureException(
|
||||
error: unknown,
|
||||
properties?: Record<string, unknown>,
|
||||
): void {
|
||||
if (!enabled) return;
|
||||
posthog.captureException(error, properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a failed API call.
|
||||
*
|
||||
* Called from the axios interceptor rather than from `emitApiError`, which
|
||||
* early-returns on suppressed paths (warehouse / first-mile / onboarding /
|
||||
* auth) — hooking there would drop errors on exactly those pages.
|
||||
*
|
||||
* 5xx and network failures are real defects and go to Error tracking. 4xx is
|
||||
* usually the server correctly rejecting input, so it is recorded as a plain
|
||||
* event to keep the issue list signal-heavy.
|
||||
*/
|
||||
export function captureApiError(error: unknown): void {
|
||||
if (!enabled) return;
|
||||
|
||||
const err = error as {
|
||||
message?: string;
|
||||
config?: { method?: string; url?: string };
|
||||
response?: { status?: number };
|
||||
};
|
||||
|
||||
const status = err.response?.status;
|
||||
const properties = {
|
||||
api_status: status ?? null,
|
||||
api_method: err.config?.method?.toUpperCase() ?? null,
|
||||
api_path: err.config?.url ?? null,
|
||||
};
|
||||
|
||||
if (status && status < 500) {
|
||||
posthog.capture("api_error", properties);
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.captureException(error, properties);
|
||||
}
|
||||
|
||||
/** Company context, as returned by `useAuth().company`. */
|
||||
interface IdentifyCompany {
|
||||
company?: { id?: string; type?: string | null; status?: string | null } | null;
|
||||
profile?: { activeProfileType?: string | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the current user to PostHog so replays and exceptions are
|
||||
* attributable.
|
||||
*
|
||||
* Deliberately sends NO contact details. `AuthUser` carries email, phoneNumber,
|
||||
* name and username; these are real customers, and debugging a replay never
|
||||
* requires knowing how to phone the person in it.
|
||||
*/
|
||||
export function useIdentify(
|
||||
user: AuthUser | null,
|
||||
company?: IdentifyCompany | null,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
if (!user?.id) {
|
||||
posthog.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.identify(user.id, {
|
||||
roles: user.roles,
|
||||
status: user.status,
|
||||
user_type: user.userType,
|
||||
company_id: company?.company?.id,
|
||||
company_type: company?.company?.type,
|
||||
company_status: company?.company?.status,
|
||||
active_profile_type: company?.profile?.activeProfileType,
|
||||
});
|
||||
}, [
|
||||
user?.id,
|
||||
user?.roles,
|
||||
user?.status,
|
||||
user?.userType,
|
||||
company?.company?.id,
|
||||
company?.company?.type,
|
||||
company?.company?.status,
|
||||
company?.profile?.activeProfileType,
|
||||
]);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import posthog from "posthog-js";
|
||||
import { PostHogProvider, PostHogErrorBoundary } from "@posthog/react";
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/dates/styles.css";
|
||||
import "@edr/ui-common/styles.css";
|
||||
@@ -10,6 +12,8 @@ import "../index.css";
|
||||
import "@edr/ui-common/theme.css";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { mantineTheme } from "./theme/mantine";
|
||||
import { initPostHog } from "./lib/posthog";
|
||||
import { AppErrorFallback } from "./components/errors/AppErrorFallback";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
@@ -26,6 +30,10 @@ import App from "./App";
|
||||
}
|
||||
});
|
||||
|
||||
// Must run before render so replay and exception capture cover startup errors.
|
||||
// No-ops when VITE_POSTHOG_KEY is unset.
|
||||
initPostHog();
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
@@ -36,13 +44,17 @@ if (!rootElement) {
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<MantineProvider theme={mantineTheme} defaultColorScheme="light">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<Toaster position="top-right" />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
<PostHogProvider client={posthog}>
|
||||
<MantineProvider theme={mantineTheme} defaultColorScheme="light">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<PostHogErrorBoundary fallback={<AppErrorFallback />}>
|
||||
<App />
|
||||
</PostHogErrorBoundary>
|
||||
<Toaster position="top-right" />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
</PostHogProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
emitApiError,
|
||||
extractApiErrorPayload,
|
||||
} from "@/components/errors/ApiErrorModal";
|
||||
import { captureApiError } from "@/lib/posthog";
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
@@ -91,6 +92,14 @@ client.interceptors.response.use(
|
||||
_retry?: boolean;
|
||||
};
|
||||
|
||||
// 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.
|
||||
// 401s are skipped: an expired session is refreshed below, not a defect.
|
||||
if (!error.response || error.response.status !== 401) {
|
||||
captureApiError(error);
|
||||
}
|
||||
|
||||
// Don't intercept if:
|
||||
// - no response (network error)
|
||||
// - status is not 401
|
||||
|
||||
@@ -16,7 +16,13 @@ interface Window {
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_BASE_API_URL: string;
|
||||
readonly VITE_GOOGLE_MAPS_API_KEY?: string;
|
||||
readonly VITE_TOKEN_REFRESH_INTERVAL_MINUTES?: string;
|
||||
/** PostHog project token. Absent = observability disabled (see lib/posthog.ts). */
|
||||
readonly VITE_POSTHOG_KEY?: string;
|
||||
/** Self-hosted PostHog instance URL. */
|
||||
readonly VITE_POSTHOG_HOST?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Reference in New Issue
Block a user