diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 5bce9de95..2b3b855cb 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -49,6 +49,9 @@ MINIO_PORT=9000 MINIO_USE_SSL=false MINIO_ACCESS_KEY= MINIO_SECRET_KEY= +# Preset region so signed URLs are generated locally (no GetBucketLocation +# network call per sign). MinIO's default is us-east-1. +MINIO_REGION=us-east-1 # Redis REDIS_HOST=localhost diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index f9107ed23..7525eda43 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -17,6 +17,10 @@ RUN pnpm dlx turbo prune "@edr/freight-api" --docker FROM base AS installer COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml +# Puppeteer's bundled Chromium can't run on Alpine (glibc build). Skip its +# download here — the runner installs Alpine's system Chromium instead and we +# point PUPPETEER_EXECUTABLE_PATH at it. +ENV PUPPETEER_SKIP_DOWNLOAD=true RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile @@ -28,12 +32,26 @@ RUN pnpm turbo build --filter="@edr/freight-api..." FROM base AS deployer COPY --from=builder /app/ . +ENV PUPPETEER_SKIP_DOWNLOAD=true RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy FROM node:24.15.0-alpine AS runner -RUN apk add --no-cache libc6-compat +# Chromium + fonts so puppeteer can render contract PDFs (HTML -> PDF). Without +# a working browser the PDF service falls back to an unstyled text-only PDF. +RUN apk add --no-cache \ + libc6-compat \ + chromium \ + nss \ + freetype \ + harfbuzz \ + ttf-freefont \ + font-noto \ + font-noto-cjk ENV NODE_ENV=production +# Point puppeteer at the system Chromium and stop it trying to download its own. +ENV PUPPETEER_SKIP_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 --ingroup nodejs nestjs diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 34a958fd2..e53ba0e13 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -45,16 +45,23 @@ export class ContractsRepository extends BaseRepository { return this.repository.findOne({ where: { reference } }); } - /** Count contracts created in a specific year. */ - async countByYear(year: number): Promise { - const startDate = new Date(year, 0, 1); - const endDate = new Date(year + 1, 0, 1); - - return this.repository + /** + * Highest NNNNN sequence already issued for `CTR--…` references. + * Includes soft-deleted contracts — their references still occupy the unique + * index, so the next number must move past them. (A created-at count drifts + * below the issued sequence after any delete and then collides forever.) + */ + async maxReferenceSequence(year: number): Promise { + const row = await this.repository .createQueryBuilder('contract') - .where('contract.created_at >= :startDate', { startDate }) - .andWhere('contract.created_at < :endDate', { endDate }) - .getCount(); + .withDeleted() + .select( + "COALESCE(MAX(CAST(SUBSTRING(contract.reference FROM '[0-9]+$') AS int)), 0)", + 'max', + ) + .where('contract.reference LIKE :prefix', { prefix: `CTR-${year}-%` }) + .getRawOne<{ max: string | number | null }>(); + return Number(row?.max ?? 0); } /** Find a contract by ID with all child collections, service type, company and files. */ diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 8d864aac7..975b977a6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -57,8 +57,8 @@ export class ContractsService { /** Generate a unique contract reference number (CTR-YYYY-NNNNN). */ private async generateReference(): Promise { const year = new Date().getFullYear(); - const count = await this.contractsRepository.countByYear(year); - return `CTR-${year}-${String(count + 1).padStart(5, '0')}`; + const seq = await this.contractsRepository.maxReferenceSequence(year); + return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`; } /** Whether a service type bundles customs clearance. */ @@ -144,8 +144,6 @@ export class ContractsService { this.assertCargoScopeShape(dto.freightType, dto.cargoScope); this.assertRouteShape(dto.contractKind, dto.routes); - const reference = dto.reference || (await this.generateReference()); - // Stamp the operational profile (importer/exporter) for portal scoping. let companyProfileId: string | null = null; if (!isGovernment && companyId) { @@ -177,34 +175,28 @@ export class ContractsService { // Customs clearing is owned by the service type, not the customer. const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId); - const contract = await this.contractsRepository.create({ - reference, - companyId: companyId ?? null, - companyProfileId, - isGovernment, - governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null, - contractKind: dto.contractKind, - renewalOfId: dto.renewalOfId ?? null, - tradeDirection: dto.tradeDirection, - freightType: dto.freightType, - serviceTypeId: dto.serviceTypeId, - paymentCurrency: dto.paymentCurrency, - customsClearingEnabled: includesCustoms, - customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null), - equipmentReturn: dto.equipmentReturn ?? null, - firstMilePickupAddress: dto.firstMilePickupAddress ?? null, - firstMilePickupLat: dto.firstMilePickupLat ?? null, - firstMilePickupLng: dto.firstMilePickupLng ?? null, - lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null, - lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null, - lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, - isHazardous: dto.isHazardous ?? false, - isReefer: dto.isReefer ?? false, - contractType: dto.contractType ?? null, - status: 'DRAFT', - clearanceStatus: 'NOT_APPLICABLE', - clearanceCycleNumber: 0, - } as never); + // An explicit reference is caller-chosen — a collision there is a real + // conflict and should surface. Auto-generated references retry past a + // concurrent insert that grabbed the same sequence number. + const contract = dto.reference + ? await this.insertContract(dto.reference, { + companyId, + companyProfileId, + isGovernment, + includesCustoms, + dto, + }) + : await insertWithGeneratedReference( + () => this.generateReference(), + (reference) => + this.insertContract(reference, { + companyId, + companyProfileId, + isGovernment, + includesCustoms, + dto, + }), + ); await this.persistRoutes(contract.id, dto.routes); await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind); diff --git a/apps/edr-freight-api/src/modules/minio/minio.config.ts b/apps/edr-freight-api/src/modules/minio/minio.config.ts index 10482a325..a6decd1d0 100644 --- a/apps/edr-freight-api/src/modules/minio/minio.config.ts +++ b/apps/edr-freight-api/src/modules/minio/minio.config.ts @@ -7,4 +7,9 @@ export const minioConfig = registerAs("minio", () => ({ accessKey: process.env.MINIO_ACCESS_KEY || "", secretKey: process.env.MINIO_SECRET_KEY || "", bucket: process.env.MINIO_BUCKET || "fhc", + // Preset the region so presignedGetObject signs URLs locally. Without it the + // minio client fires a live GetBucketLocation request to the endpoint on every + // sign — which blocks (no timeout) when MinIO is slow/unreachable and hangs + // API responses that reload a booking's files (e.g. staff accept). + region: process.env.MINIO_REGION || "us-east-1", })); diff --git a/apps/edr-freight-api/src/modules/minio/minio.service.ts b/apps/edr-freight-api/src/modules/minio/minio.service.ts index 086da89f9..4b0804d62 100644 --- a/apps/edr-freight-api/src/modules/minio/minio.service.ts +++ b/apps/edr-freight-api/src/modules/minio/minio.service.ts @@ -29,6 +29,9 @@ export class MinioService { useSSL: config.useSSL, accessKey: config.accessKey, secretKey: config.secretKey, + // Presetting the region keeps presignedGetObject fully local — no live + // GetBucketLocation round-trip to the endpoint on each signed URL. + region: config.region, }); } @@ -108,8 +111,11 @@ export class MinioService { try { return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds); } catch (error) { + // Signing a file URL must never break a booking/transition response — the + // caller only needs SOMETHING to link to. Degrade to the public object URL + // and log, rather than throwing (which would 500 an otherwise-good load). this.logger.error(`Failed to generate signed URL for ${objectName}:`, error); - throw error; + return this.getPublicUrl(objectName); } } } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index 7dde7666b..6bd6994f6 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -526,23 +526,13 @@ export default function NewContractPage({ }, ]; - // Routes — pure origin→destination lanes, no quantity. Route #1 is primary; - // extras only apply to GENERAL contracts. + // Route — a single origin→destination lane, general contracts included. const routes: Freight.CreateContractRouteInputDto[] = [ { originYardId: data.originYard, destinationYardId: data.destinationYard, sortOrder: 0, }, - ...(isGeneral - ? (data.extraRoutes ?? []) - .filter((r) => r.originYard && r.destinationYard) - .map((r, i) => ({ - originYardId: r.originYard, - destinationYardId: r.destinationYard, - sortOrder: i + 1, - })) - : []), ]; return { diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts index 8916ef67d..b2fc5168c 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts @@ -52,13 +52,9 @@ export function contractToFormValues( const routes = [...(contract.routes ?? [])].sort( (a, b) => a.sortOrder - b.sortOrder, ); + // Contracts carry a single route now; older multi-route GENERAL contracts + // load only their primary route. const primaryRoute = routes[0]; - const extraRoutes = isGeneral - ? routes.slice(1).map((r) => ({ - originYard: r.originYardId, - destinationYard: r.destinationYardId, - })) - : []; const scope = contract.cargoScope ?? []; @@ -131,7 +127,6 @@ export function contractToFormValues( originYard: primaryRoute?.originYardId ?? "", destinationYard: primaryRoute?.destinationYardId ?? "", - extraRoutes, documents: {}, }; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts index df0705e68..3cf78fd34 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts @@ -73,7 +73,7 @@ export const CONTRACT_KIND_OPTIONS: Array<{ { value: "general_contract", label: "General Contract", - description: "Ship multiple times over the validity window across routes.", + description: "Ship multiple times over the validity window on one route.", }, ]; @@ -107,9 +107,9 @@ export const CONTAINER_SIZES = ["20ft", "40ft"] as const; export type ContainerSize = (typeof CONTAINER_SIZES)[number]; // A GENERAL-contract quantity cap. The Mantine NumberInput backing these fields -// can briefly emit "" / undefined / NaN (cleared or never-touched field); those -// all mean "uncapped", so coerce them to 0 before the >= 0 check rather than -// letting them fail validation and silently block the Cargo & Route step. +// can briefly emit "" / undefined / NaN (cleared or never-touched field); +// coerce those to 0 so the superRefine below can flag them with a clear +// "greater than 0" message instead of a type error. const nonNegativeQuantityCap = z.preprocess( (v) => v === "" || v === null || v === undefined || Number.isNaN(v) ? 0 : v, @@ -167,34 +167,23 @@ export const contractFormSchema = z // contract_cargo_scope row. enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).default([]), // GENERAL only: per-size container quantity cap (total bookable over the - // validity window). Keyed by size; 0/undefined = uncapped. The NumberInput - // can momentarily hold "" / undefined (empty field) — coerce those to 0 so - // an untouched cap never blocks the step. + // validity window). Keyed by size; must be > 0 for every enabled size + // (enforced in the superRefine below). containerSizeCaps: z .record(z.string(), nonNegativeQuantityCap) .default({}), // Bulk scope: the cargo type path (group → commodity). cargoTypePath: z.array(z.string()).default([]), cargoFreeText: z.string().default(""), - // GENERAL only: total bulk tons/items bookable. 0 = uncapped. Same empty- - // field coercion as the container caps above. + // GENERAL only: total bulk tons/items bookable; must be > 0 (superRefine). bulkQuantityCap: nonNegativeQuantityCap.default(0), // Contract-level billing flags. isHazardous: z.boolean().default(false), isRefrigerated: z.boolean().default(false), - // ── Route ── + // ── Route ── (one route per contract — general contracts included) originYard: z.string().min(1, "Select an origin yard."), destinationYard: z.string().min(1, "Select a destination yard."), - // Additional routes for a GENERAL contract (route #1 is the primary above). - extraRoutes: z - .array( - z.object({ - originYard: z.string().default(""), - destinationYard: z.string().default(""), - }), - ) - .default([]), documents: z.record(z.string(), z.any()).default({}), notes: z.string().default(""), @@ -260,6 +249,29 @@ export const contractFormSchema = z }); } } + // GENERAL contracts must carry a real (> 0) quantity cap — an untouched + // NumberInput coerces to 0 (see nonNegativeQuantityCap), which blocks the + // Cargo & Route step until the customer enters a quantity. + if (data.contractKind === "general_contract") { + if (data.cargoType === "container") { + for (const size of data.enabledContainerSizes) { + if (!(data.containerSizeCaps[size] > 0)) { + ctx.addIssue({ + code: "custom", + path: ["containerSizeCaps", size], + message: `Enter a ${size} quantity greater than 0.`, + }); + } + } + } + if (data.cargoType === "bulk" && !(data.bulkQuantityCap > 0)) { + ctx.addIssue({ + code: "custom", + path: ["bulkQuantityCap"], + message: "Enter a total quantity greater than 0.", + }); + } + } }); export type ContractFormValues = z.infer; @@ -289,7 +301,6 @@ export const initialContractFormValues: DeepPartial = { originYard: "", destinationYard: "", - extraRoutes: [], documents: {}, notes: "", @@ -325,7 +336,6 @@ export const contractStepFields: Record< "isRefrigerated", "originYard", "destinationYard", - "extraRoutes", ], // Step 2 — Review & Submit. (The separate Documents step was removed — the // company profile documents are attached to the contract automatically.) diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx index eec7e896b..a4ec5aeb8 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx @@ -132,19 +132,12 @@ export function Step1ContractType({ ); form.setValue("customsClearingAgent", contract.customsClearingAgent ?? ""); - // ── Route (primary + extras) ── + // ── Route (single route per contract) ── const routes = contract.routes ?? []; if (routes[0]) { form.setValue("originYard", routes[0].originYardId); form.setValue("destinationYard", routes[0].destinationYardId); } - form.setValue( - "extraRoutes", - routes.slice(1).map((r) => ({ - originYard: r.originYardId, - destinationYard: r.destinationYardId, - })), - ); // ── Cargo scope ── form.setValue( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx index 5f13d889a..d278c15ea 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx @@ -227,14 +227,14 @@ export function Step3CargoScope({ {/* GENERAL contract quantity cap (draw-down ceiling). */} {isGeneral && ( - Booking quantity cap (optional) + Booking quantity cap * Total quantity bookable across all shipments under this contract. - Customers / GL can book repeatedly until it is reached. Leave 0 for - unlimited. + Customers / GL can book repeatedly until it is reached. Must be + greater than 0. {cargoType === "container" ? ( - + {enabledSizes.length === 0 ? ( Select container sizes above to set their caps. @@ -245,13 +245,14 @@ export function Step3CargoScope({ key={size} name={`containerSizeCaps.${size}`} control={form.control} - render={({ field }) => ( + render={({ field, fieldState }) => ( field.onChange(Number(v) || 0)} + error={fieldState.error?.message} radius={10} styles={fieldStyles} /> @@ -264,13 +265,14 @@ export function Step3CargoScope({ ( + render={({ field, fieldState }) => ( field.onChange(Number(v) || 0)} + error={fieldState.error?.message} radius={10} styles={fieldStyles} /> diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx index 1e9ead268..7b96676ef 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx @@ -1,12 +1,8 @@ import type { Freight } from "@edr/types"; -import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core"; -import { MapPin, Plus, Trash2 } from "lucide-react"; +import { Skeleton, Stack } from "@mantine/core"; +import { MapPin } from "lucide-react"; import { useCallback, useEffect, useMemo } from "react"; -import { - Controller, - useFieldArray, - type UseFormReturn, -} from "react-hook-form"; +import { Controller, type UseFormReturn } from "react-hook-form"; import { ContractFormInputValues, type ContractFormValues } from "./schema"; import { getRouteDirection } from "./helpers"; import { SelectField, StepLabel } from "./shared"; @@ -29,7 +25,6 @@ export function Step4Route({ const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); const operationType = form.watch("operationType"); - const isGeneralContract = form.watch("contractKind") === "general_contract"; const { originCountry, destinationCountry } = useMemo(() => { switch (operationType) { @@ -46,14 +41,6 @@ export function Step4Route({ } }, [operationType]); - const { - fields: extraRoutes, - append: appendRoute, - remove: removeRoute, - } = useFieldArray({ control: form.control, name: "extraRoutes" }); - - const watchedExtraRoutes = form.watch("extraRoutes") ?? []; - const yardOptions = useMemo(() => { if (!referenceData?.yard) return []; return referenceData.yard.map((y) => ({ value: y.id, label: y.name })); @@ -96,22 +83,6 @@ export function Step4Route({ } }, [destinationCountry, dest, form]); - useEffect(() => { - watchedExtraRoutes.forEach((route, i) => { - const ro = referenceData?.yard.find((y) => y.id === route?.originYard); - if (originCountry && ro && ro.country !== originCountry) { - form.setValue(`extraRoutes.${i}.originYard`, ""); - } - const rd = referenceData?.yard.find( - (y) => y.id === route?.destinationYard, - ); - if (destinationCountry && rd && rd.country !== destinationCountry) { - form.setValue(`extraRoutes.${i}.destinationYard`, ""); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [originCountry, destinationCountry, referenceData, form]); - const directionStyle: Record = { EXPORT: "bg-sky-50 text-sky-800 border-sky-200", IMPORT: "bg-amber-50 text-amber-800 border-amber-200", @@ -173,94 +144,6 @@ export function Step4Route({ )} - - {isGeneralContract && !isLoading && ( - - - Additional contract routes - - - - A general contract can cover several routes. The route above is your - primary route; add more origin–destination routes the contract - should cover. - - - {extraRoutes.map((rf, i) => { - const rowOrigin = watchedExtraRoutes[i]?.originYard ?? ""; - const rowDestination = - watchedExtraRoutes[i]?.destinationYard ?? ""; - const rowOriginData = yardsForSide(originCountry, rowDestination); - const rowDestData = yardsForSide(destinationCountry, rowOrigin); - return ( - - - ( - - )} - /> - - - ( - - )} - /> - - - - ); - })} - - - )} ); } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx index 8f53b1b64..bbb262724 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx @@ -250,10 +250,6 @@ export function Step8Review({ ? direction.charAt(0) + direction.slice(1).toLowerCase() : "—"; - const routesCount = 1 + (values.extraRoutes?.filter( - (r) => r.originYard && r.destinationYard, - ).length ?? 0); - return ( } - label="Primary route" + label="Route" value={`${originYardName} → ${destinationYardName}`} /> } label="Trade direction" - value={ - isGeneralContract - ? `${directionLabel} · ${routesCount} routes` - : directionLabel - } + value={directionLabel} /> } diff --git a/packages/api-common/src/index.ts b/packages/api-common/src/index.ts index f0a25158d..8561f3721 100644 --- a/packages/api-common/src/index.ts +++ b/packages/api-common/src/index.ts @@ -20,3 +20,6 @@ export * from "./repositories/base.repository"; // Services export * from "./services/exchange"; + +// Utils +export * from "./utils/reference-sequence"; diff --git a/packages/api-common/src/utils/reference-sequence.ts b/packages/api-common/src/utils/reference-sequence.ts new file mode 100644 index 000000000..ecdc3e18f --- /dev/null +++ b/packages/api-common/src/utils/reference-sequence.ts @@ -0,0 +1,50 @@ +import { QueryFailedError } from "typeorm"; + +/** Postgres unique-violation SQLSTATE. */ +const PG_UNIQUE_VIOLATION = "23505"; + +/** + * True when `error` is a Postgres unique-constraint violation. Used to detect a + * reference-number collision from a concurrent insert so the caller can retry + * with a freshly-computed number instead of surfacing a 500. + */ +export function isUniqueViolation(error: unknown): boolean { + if (!(error instanceof QueryFailedError)) return false; + const driver = ( + error as QueryFailedError & { driverError?: { code?: string } } + ).driverError; + return driver?.code === PG_UNIQUE_VIOLATION; +} + +/** + * Run `insert(reference)` under a "generate → try → retry on collision" loop. + * + * `MAX(sequence) + 1` alone is not concurrency-safe: two requests can read the + * same max and derive the same reference, and one insert then hits the unique + * index. On that collision we recompute the reference and try again, so the + * sequence advances under load instead of throwing. Non-collision errors (and + * exhausting the attempt budget) propagate unchanged. + * + * @param generate Async producer of the next reference (e.g. `CTR-2026-00033`). + * Re-invoked on each attempt so it re-reads the current max. + * @param insert Performs the insert with the given reference; its result is + * returned on success. + * @param attempts Maximum tries before giving up (default 5). + */ +export async function insertWithGeneratedReference( + generate: () => Promise, + insert: (reference: string) => Promise, + attempts = 5, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt++) { + const reference = await generate(); + try { + return await insert(reference); + } catch (error) { + if (!isUniqueViolation(error)) throw error; + lastError = error; + } + } + throw lastError; +}