mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
chnages
This commit is contained in:
@@ -60,6 +60,8 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
|
||||
unitDefault: "PCS",
|
||||
incomeWithholdValue: 0,
|
||||
transactionWithholdValue: 0,
|
||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||
buyerWeredaCodes: {},
|
||||
...over,
|
||||
});
|
||||
|
||||
@@ -130,7 +132,7 @@ describe("toEimsInvoice", () => {
|
||||
ExciseTaxValue: 0,
|
||||
TotalLineAmount: 11500,
|
||||
Unit: "PCS",
|
||||
NatureOfSupplies: "Service",
|
||||
NatureOfSupplies: "service",
|
||||
HarmonizationCode: null,
|
||||
});
|
||||
expect(doc.ItemList[1]).toMatchObject({
|
||||
@@ -207,6 +209,77 @@ describe("toEimsInvoice", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toEimsInvoice — MoR field constraints", () => {
|
||||
it("passes a buyer region through when it is already a MoR code", () => {
|
||||
const doc = toEimsInvoice(invoice(), seller, context());
|
||||
expect(doc.BuyerDetails.Region).toBe("13");
|
||||
});
|
||||
|
||||
it("maps a region name to its code, ignoring case and spacing", () => {
|
||||
const doc = toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, region: " addis ababa " } }),
|
||||
seller,
|
||||
context({ buyerRegionCodes: { "Addis Ababa": "13" } }),
|
||||
);
|
||||
expect(doc.BuyerDetails.Region).toBe("13");
|
||||
});
|
||||
|
||||
it("refuses to file a buyer whose region has no mapping", () => {
|
||||
expect(() =>
|
||||
toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, region: "Somewhere Else" } }),
|
||||
seller,
|
||||
context(),
|
||||
),
|
||||
).toThrow(/not a MoR Region code and has no mapping/);
|
||||
});
|
||||
|
||||
it("refuses a buyer with no region at all rather than guessing one", () => {
|
||||
expect(() =>
|
||||
toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, region: null } }),
|
||||
seller,
|
||||
context(),
|
||||
),
|
||||
).toThrow(/buyer Region \(unset\)/);
|
||||
});
|
||||
|
||||
it("passes a buyer wereda through when it is already a MoR code", () => {
|
||||
const doc = toEimsInvoice(invoice(), seller, context());
|
||||
expect(doc.BuyerDetails.Wereda).toBe("574");
|
||||
});
|
||||
|
||||
it("maps a wereda name to its code", () => {
|
||||
const doc = toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
|
||||
seller,
|
||||
context({ buyerWeredaCodes: { Yeka: "99" } }),
|
||||
);
|
||||
expect(doc.BuyerDetails.Wereda).toBe("99");
|
||||
});
|
||||
|
||||
it("refuses to file a buyer whose wereda has no mapping", () => {
|
||||
expect(() =>
|
||||
toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
|
||||
seller,
|
||||
context({ buyerWeredaCodes: {} }),
|
||||
),
|
||||
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
|
||||
});
|
||||
|
||||
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {
|
||||
const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" }));
|
||||
expect(doc.ItemList[0].NatureOfSupplies).toBe("service");
|
||||
});
|
||||
|
||||
it("rejects a NatureOfSupplies MoR does not accept", () => {
|
||||
expect(() =>
|
||||
toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Services" })),
|
||||
).toThrow(/must be one of goods, service/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatEimsDate", () => {
|
||||
it("renders the observed dd-MM-yyyyTHH:mm:ss shape with zero padding", () => {
|
||||
expect(formatEimsDate(new Date(2025, 2, 21, 0, 0, 0))).toBe("21-03-2025T00:00:00");
|
||||
|
||||
@@ -210,6 +210,22 @@ export interface EimsMapperContext {
|
||||
relatedDocument?: string | null;
|
||||
/** MoR numeric country code for the buyer; our DB stores the country name. */
|
||||
buyerCountryCode?: string | null;
|
||||
/**
|
||||
* Region name → MoR numeric code, for buyers whose stored region is free text.
|
||||
*
|
||||
* `companies.region` holds names ("Addis Ababa") while MoR validates `BuyerDetails.Region`
|
||||
* against `^[0-9]{1,3}$`. A stored value that is already a code passes through; anything else
|
||||
* must be in this map or the mapping **fails locally** — sending a guessed region code onto a
|
||||
* tax document is worse than refusing to file.
|
||||
*/
|
||||
buyerRegionCodes: Record<string, string>;
|
||||
/**
|
||||
* Wereda name → MoR code, same shape as `buyerRegionCodes`. `companies.woreda` holds names
|
||||
* ("Yeka") or codes inconsistently; unlike Region, MoR has never named a Wereda regex in an
|
||||
* error, so this is precautionary rather than confirmed — but the fix is identical either way:
|
||||
* fail locally on an unmapped name rather than file a guess.
|
||||
*/
|
||||
buyerWeredaCodes: Record<string, string>;
|
||||
buyerIdType?: string | null;
|
||||
buyerIdNumber?: string | null;
|
||||
buyerCity?: string | null;
|
||||
@@ -220,6 +236,22 @@ export interface EimsMapperContext {
|
||||
formatDate?: (issuedAt: Date) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MoR's own constraint on `Region`: one to three digits, confirmed by its 400 SCHEMA ERROR. Reused
|
||||
* as the pass-through test for `Wereda` too — every Wereda value MoR has actually shown us (seller
|
||||
* "12"/"13", the collection's "574") fits the same shape, though MoR has not named a Wereda regex
|
||||
* the way it named Region's.
|
||||
*/
|
||||
const LOCATION_CODE = /^[0-9]{1,3}$/;
|
||||
|
||||
/**
|
||||
* The only two values MoR accepts for `NatureOfSupplies`, lowercase.
|
||||
*
|
||||
* Its schema branches on this as a `oneOf` with a `const` per branch, so `"Service"` fails the
|
||||
* whole `ItemList` — the error reads "must be the constant value 'service'".
|
||||
*/
|
||||
const NATURE_OF_SUPPLIES = ["goods", "service"] as const;
|
||||
|
||||
const num = (v: number | string): number => {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) throw new Error(`EIMS mapping: expected a numeric value, got ${String(v)}`);
|
||||
@@ -240,6 +272,33 @@ export const formatEimsDate = (issuedAt: Date): string =>
|
||||
* an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no
|
||||
* exchange rate.
|
||||
*/
|
||||
/**
|
||||
* A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric,
|
||||
* otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending
|
||||
* a guessed code onto a tax document is worse than refusing to file.
|
||||
*/
|
||||
function resolveLocationCode(
|
||||
field: "Region" | "Wereda",
|
||||
value: string | null | undefined,
|
||||
codes: Record<string, string>,
|
||||
envVar: string,
|
||||
invoiceNumber: string,
|
||||
): string {
|
||||
const raw = (value ?? "").trim();
|
||||
if (LOCATION_CODE.test(raw)) return raw;
|
||||
|
||||
const key = raw.toLowerCase().replace(/\s+/g, " ");
|
||||
const mapped = Object.entries(codes).find(
|
||||
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
|
||||
)?.[1];
|
||||
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
|
||||
|
||||
throw new Error(
|
||||
`EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` +
|
||||
`which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function toEimsInvoice(
|
||||
invoice: EimsMapperInvoice,
|
||||
seller: EimsSellerDetails,
|
||||
@@ -266,6 +325,14 @@ export function toEimsInvoice(
|
||||
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`);
|
||||
}
|
||||
|
||||
const natureOfSupplies = context.natureOfSupplies.trim().toLowerCase();
|
||||
if (!NATURE_OF_SUPPLIES.includes(natureOfSupplies as (typeof NATURE_OF_SUPPLIES)[number])) {
|
||||
throw new Error(
|
||||
`EIMS mapping: NatureOfSupplies must be one of ${NATURE_OF_SUPPLIES.join(", ")}, ` +
|
||||
`got "${context.natureOfSupplies}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => {
|
||||
const lineNumber = index + 1;
|
||||
const tax = context.taxForLine(line, lineNumber);
|
||||
@@ -285,7 +352,7 @@ export function toEimsInvoice(
|
||||
Discount: 0,
|
||||
ExciseTaxValue,
|
||||
HarmonizationCode: null,
|
||||
NatureOfSupplies: context.natureOfSupplies,
|
||||
NatureOfSupplies: natureOfSupplies,
|
||||
ItemCode: line.chargeType,
|
||||
ProductDescription: line.description?.trim() || line.chargeType,
|
||||
PreTaxValue,
|
||||
@@ -329,12 +396,24 @@ export function toEimsInvoice(
|
||||
Tin: company.tin,
|
||||
LegalName: company.name,
|
||||
Phone: company.phone ?? null,
|
||||
Region: company.region ?? null,
|
||||
Region: resolveLocationCode(
|
||||
"Region",
|
||||
company.region,
|
||||
context.buyerRegionCodes,
|
||||
"EIMS_BUYER_REGION_CODES",
|
||||
invoice.invoiceNumber,
|
||||
),
|
||||
Country: context.buyerCountryCode ?? null,
|
||||
Zone: company.zone ?? null,
|
||||
Kebele: company.kebele ?? null,
|
||||
VatNumber: company.vatNumber ?? null,
|
||||
Wereda: company.woreda ?? null,
|
||||
Wereda: resolveLocationCode(
|
||||
"Wereda",
|
||||
company.woreda,
|
||||
context.buyerWeredaCodes,
|
||||
"EIMS_BUYER_WEREDA_CODES",
|
||||
invoice.invoiceNumber,
|
||||
),
|
||||
},
|
||||
DocumentDetails: {
|
||||
DocumentNumber: context.documentNumber,
|
||||
|
||||
@@ -115,6 +115,10 @@ export class Invoice extends BaseEntity {
|
||||
@Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true })
|
||||
eimsIrn?: string | null;
|
||||
|
||||
/** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
|
||||
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
|
||||
eimsDocumentNumber?: string | null;
|
||||
|
||||
/** The `SourceSystem.InvoiceCounter` this invoice consumed. */
|
||||
@Column({ name: "eims_invoice_counter", type: "bigint", nullable: true })
|
||||
eimsInvoiceCounter?: number | null;
|
||||
|
||||
@@ -57,6 +57,37 @@ export function assertEimsInvoiceConfig(config: EimsConfig): void {
|
||||
`(tax values need finance sign-off — they are deliberately not defaulted): ${missing.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
assertSellerFormats(config.invoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* MoR's own patterns for the seller fields, checked here rather than at the gateway.
|
||||
*
|
||||
* A placeholder like `_` is "set" but unfilable, and finding that out costs a real request and a
|
||||
* consumed counter — these are the exact regexes its 400 SCHEMA ERROR quoted back at us.
|
||||
*/
|
||||
const SELLER_FORMATS: { env: string; value: (i: EimsConfig["invoice"]) => string; pattern: RegExp }[] = [
|
||||
{ env: "EIMS_SELLER_PHONE", value: (i) => i.sellerPhone, pattern: /^\+?[0-9]{6,}$/ },
|
||||
{
|
||||
env: "EIMS_SELLER_EMAIL",
|
||||
value: (i) => i.sellerEmail,
|
||||
pattern: /^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$/,
|
||||
},
|
||||
{ env: "EIMS_SELLER_REGION", value: (i) => i.sellerRegion, pattern: /^[0-9]{1,3}$/ },
|
||||
{ env: "EIMS_SELLER_WEREDA", value: (i) => i.sellerWereda, pattern: /^[0-9A-Za-z]{1,10}$/ },
|
||||
];
|
||||
|
||||
function assertSellerFormats(invoice: EimsConfig["invoice"]): void {
|
||||
const bad = SELLER_FORMATS.filter(({ value, pattern }) => !pattern.test(value(invoice))).map(
|
||||
({ env, pattern }) => `${env} (must match ${pattern.source})`,
|
||||
);
|
||||
if (bad.length > 0) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_INVOICE_CONFIG_INVALID",
|
||||
message: `EIMS seller details would be rejected by MoR: ${bad.join("; ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEimsSeller(config: EimsConfig): EimsSellerDetails {
|
||||
@@ -112,6 +143,8 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
||||
incomeWithholdValue: invoice.incomeWithholdValue!,
|
||||
transactionWithholdValue: invoice.transactionWithholdValue!,
|
||||
buyerCountryCode: invoice.buyerCountryCode,
|
||||
buyerRegionCodes: invoice.buyerRegionCodes,
|
||||
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
||||
exchangeRate: input.exchangeRate ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { EimsConfig } from "../../config/eims.config";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
|
||||
import { eimsInvoiceConfig } from "./eims-test-fixtures";
|
||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsApiException } from "./eims.errors";
|
||||
@@ -91,9 +92,11 @@ class FakeDb {
|
||||
id: "state-1",
|
||||
systemNumber: SYSTEM_NUMBER,
|
||||
nextInvoiceCounter: 7,
|
||||
nextDocumentNumber: 5,
|
||||
previousIrn: null,
|
||||
inFlightInvoiceId: null,
|
||||
inFlightCounter: null,
|
||||
inFlightDocumentNumber: null,
|
||||
blockedReason: null,
|
||||
...state,
|
||||
} as EimsSystemState;
|
||||
@@ -130,7 +133,10 @@ class FakeDb {
|
||||
return {
|
||||
manager: this.manager,
|
||||
getRepository: this.manager.getRepository,
|
||||
query: async () => LINES,
|
||||
query: async (sql: string) =>
|
||||
sql.includes("eims_system_state")
|
||||
? [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }]
|
||||
: LINES,
|
||||
transaction: async (body: (m: unknown) => Promise<unknown>) => {
|
||||
this.onTransaction?.();
|
||||
return body(this.manager);
|
||||
@@ -147,17 +153,26 @@ const build = (
|
||||
postSigned: jest.Mock,
|
||||
cfg: EimsConfig = config(),
|
||||
postBearer: jest.Mock = jest.fn(),
|
||||
getSessionContext: jest.Mock = jest.fn().mockResolvedValue(SESSION),
|
||||
getSessionContext: jest.Mock | undefined = undefined,
|
||||
notify: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||
) =>
|
||||
new EimsInvoiceRegistrationService(
|
||||
db.asDataSource(),
|
||||
{ get: () => cfg } as unknown as ConfigService,
|
||||
{ postSigned, postBearer } as unknown as EimsClientService,
|
||||
{ getSessionContext } as unknown as EimsAuthService,
|
||||
{
|
||||
getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION),
|
||||
} as unknown as EimsAuthService,
|
||||
{ notify } as unknown as NotificationInboxService,
|
||||
);
|
||||
|
||||
/** Document number the fixtures register under; `/v1/verify` must echo it back. */
|
||||
const DOCUMENT_NUMBER = "INV-20260807-00042";
|
||||
/**
|
||||
* Document number the fixtures register under; `/v1/verify` must echo it back.
|
||||
*
|
||||
* A plain integer, not our `invoiceNumber`: MoR validates the field against
|
||||
* `^(0|[1-9][0-9]{0,8})$`. It is allocated from `nextDocumentNumber` above.
|
||||
*/
|
||||
const DOCUMENT_NUMBER = "5";
|
||||
|
||||
/**
|
||||
* `/v1/verify` success. The response spells the reference `Irn` while the request sends lowercase
|
||||
@@ -220,7 +235,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
||||
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
||||
expect(request.SourceSystem.InvoiceCounter).toBe(42);
|
||||
expect(request.ReferenceDetails.PreviousIrn).toBe("PRIOR-IRN");
|
||||
expect(request.DocumentDetails.DocumentNumber).toBe("INV-20260807-00042");
|
||||
expect(request.DocumentDetails.DocumentNumber).toBe(DOCUMENT_NUMBER);
|
||||
expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER);
|
||||
});
|
||||
|
||||
@@ -345,7 +360,9 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
||||
inFlightInvoiceId: null,
|
||||
blockedReason: null,
|
||||
previousIrn: null,
|
||||
nextInvoiceCounter: 8, // consumed: the attempt reached the gateway
|
||||
// Returned, not consumed: MoR tracks the sequence and rejects a gap
|
||||
// ("Invoice counter is not correct. expected : 1").
|
||||
nextInvoiceCounter: 7,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -391,7 +408,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
||||
expect(postSigned).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never reuses a counter once an attempt has begun", async () => {
|
||||
it("returns the counter after a refusal, but keeps it after an ambiguous result", async () => {
|
||||
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
|
||||
const postSigned = jest
|
||||
.fn()
|
||||
@@ -404,8 +421,72 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
||||
);
|
||||
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
|
||||
|
||||
expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7);
|
||||
expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(8);
|
||||
// The two numbers move differently, because MoR constrains them differently: the counter must
|
||||
// not skip (it returns), the document number must not repeat (it is burned).
|
||||
const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
||||
const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest;
|
||||
expect(first.SourceSystem.InvoiceCounter).toBe(7);
|
||||
expect(second.SourceSystem.InvoiceCounter).toBe(7);
|
||||
expect(first.DocumentDetails.DocumentNumber).toBe("5");
|
||||
expect(second.DocumentDetails.DocumentNumber).toBe("6");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EimsInvoiceRegistrationService staff alerting", () => {
|
||||
it("raises a high-priority alert when a result is ambiguous, because all filing is blocked", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const notify = jest.fn().mockResolvedValue(undefined);
|
||||
const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT"));
|
||||
|
||||
await expect(
|
||||
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
|
||||
INVOICE_ID,
|
||||
),
|
||||
).rejects.toBeInstanceOf(EimsApiException);
|
||||
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
const sent = notify.mock.calls[0][0];
|
||||
expect(sent.priority).toBe("HIGH");
|
||||
expect(sent.title).toMatch(/blocked/i);
|
||||
expect(sent.recipients.permissionKeys).toContain("edr_freight_app:invoices:eims_resolve");
|
||||
});
|
||||
|
||||
it("raises a normal-priority alert for a deterministic rejection", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const notify = jest.fn().mockResolvedValue(undefined);
|
||||
const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406));
|
||||
|
||||
await expect(
|
||||
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
|
||||
INVOICE_ID,
|
||||
),
|
||||
).rejects.toBeInstanceOf(EimsApiException);
|
||||
|
||||
expect(notify.mock.calls[0][0].priority).toBe("NORMAL");
|
||||
});
|
||||
|
||||
it("does not alert on a successful filing", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const notify = jest.fn();
|
||||
|
||||
await build(db, jest.fn().mockResolvedValue(okResponse()), config(), jest.fn(), undefined, notify)
|
||||
.registerInvoiceWithEims(INVOICE_ID);
|
||||
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets the filing outcome stand even if the alert itself fails", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const notify = jest.fn().mockRejectedValue(new Error("inbox down"));
|
||||
const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406));
|
||||
|
||||
await expect(
|
||||
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
|
||||
INVOICE_ID,
|
||||
),
|
||||
).rejects.toThrow(/EIMS register failed \(406\)/);
|
||||
|
||||
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -447,7 +528,15 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => {
|
||||
|
||||
describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
|
||||
const blocked = () =>
|
||||
new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown, eimsInvoiceCounter: 7 })], {
|
||||
new FakeDb(
|
||||
[
|
||||
invoiceRow({
|
||||
eimsStatus: EimsInvoiceStatus.Unknown,
|
||||
eimsInvoiceCounter: 7,
|
||||
eimsDocumentNumber: DOCUMENT_NUMBER,
|
||||
}),
|
||||
],
|
||||
{
|
||||
inFlightInvoiceId: INVOICE_ID,
|
||||
inFlightCounter: 7,
|
||||
nextInvoiceCounter: 8,
|
||||
@@ -498,13 +587,13 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
|
||||
const db = blocked();
|
||||
const postBearer = jest.fn().mockResolvedValue(
|
||||
verifyResponse({
|
||||
DocumentDetails: { Type: "INV", DocumentNumber: "INV-20260807-99999" },
|
||||
DocumentDetails: { Type: "INV", DocumentNumber: "99999" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
|
||||
).rejects.toThrow(/not INV-20260807-00042/);
|
||||
).rejects.toThrow(/not 5/);
|
||||
|
||||
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
||||
eimsStatus: EimsInvoiceStatus.Unknown,
|
||||
@@ -547,7 +636,10 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
|
||||
|
||||
it("refuses to resolve an invoice that is not the in-flight one", async () => {
|
||||
const db = blocked();
|
||||
db.invoices.set(OTHER_INVOICE_ID, invoiceRow({ id: OTHER_INVOICE_ID }));
|
||||
db.invoices.set(
|
||||
OTHER_INVOICE_ID,
|
||||
invoiceRow({ id: OTHER_INVOICE_ID, eimsDocumentNumber: "6" }),
|
||||
);
|
||||
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
EimsMapperLine,
|
||||
toEimsInvoice,
|
||||
} from "../billing/eims-invoice.mapper";
|
||||
import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types";
|
||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsApiException } from "./eims.errors";
|
||||
@@ -44,6 +47,8 @@ const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AU
|
||||
interface Reservation {
|
||||
stateId: string;
|
||||
invoiceCounter: number;
|
||||
/** MoR requires a plain integer here, so it cannot be our own `invoiceNumber`. */
|
||||
documentNumber: string;
|
||||
previousIrn: string;
|
||||
}
|
||||
|
||||
@@ -72,6 +77,7 @@ export class EimsInvoiceRegistrationService {
|
||||
private readonly config: ConfigService,
|
||||
private readonly client: EimsClientService,
|
||||
private readonly auth: EimsAuthService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
private get cfg(): EimsConfig {
|
||||
@@ -98,8 +104,9 @@ export class EimsInvoiceRegistrationService {
|
||||
invoice,
|
||||
buildEimsSeller(cfg),
|
||||
buildEimsContext(cfg, {
|
||||
// Our own invoice number is the document number; EIMS only requires it to be unique.
|
||||
documentNumber: invoice.invoiceNumber,
|
||||
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
|
||||
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
|
||||
documentNumber: reservation.documentNumber,
|
||||
invoiceCounter: reservation.invoiceCounter,
|
||||
previousIrn: reservation.previousIrn,
|
||||
session,
|
||||
@@ -174,8 +181,9 @@ export class EimsInvoiceRegistrationService {
|
||||
* Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this
|
||||
* IRN is the one it holds, and that it belongs to this invoice.
|
||||
*
|
||||
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration set
|
||||
* from our own `invoiceNumber` — the only field tying an IRN back to a row in this database.
|
||||
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration
|
||||
* allocated and stored on the invoice as `eimsDocumentNumber` — the only field tying an IRN back
|
||||
* to a row in this database.
|
||||
*
|
||||
* Recording a wrong IRN is not a local mistake: it marks an unregistered invoice as filed and
|
||||
* chains every later document to a stranger's reference, so both checks are refusals rather
|
||||
@@ -231,11 +239,34 @@ export class EimsInvoiceRegistrationService {
|
||||
});
|
||||
}
|
||||
|
||||
// Cheap ownership check before touching the gateway: resolving an invoice that does not hold
|
||||
// the reservation is a caller mistake, not something to spend a MoR round trip on. The
|
||||
// authoritative re-check happens under lock in the transaction below.
|
||||
const [preState]: { in_flight_invoice_id: string | null }[] = await this.dataSource.query(
|
||||
`SELECT in_flight_invoice_id FROM freight.eims_system_state
|
||||
WHERE system_number = $1 AND deleted_at IS NULL LIMIT 1`,
|
||||
[(await this.auth.getSessionContext()).systemNumber],
|
||||
);
|
||||
if (preState?.in_flight_invoice_id && preState.in_flight_invoice_id !== invoiceId) {
|
||||
throw new ConflictException({
|
||||
code: "EIMS_RESOLVE_WRONG_INVOICE",
|
||||
message: `The in-flight EIMS submission is invoice ${preState.in_flight_invoice_id}, not ${invoiceId}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Outside the transaction: no lock is held across the wire, and a refused verification must
|
||||
// leave the block exactly as it was.
|
||||
if (irn) {
|
||||
const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId);
|
||||
await this.assertIrnBelongsToInvoice(irn, invoice.invoiceNumber);
|
||||
if (!invoice.eimsDocumentNumber) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_NO_DOCUMENT_NUMBER",
|
||||
message:
|
||||
`Invoice ${invoice.invoiceNumber} was never allocated an EIMS document number, so a ` +
|
||||
"returned IRN cannot be tied back to it.",
|
||||
});
|
||||
}
|
||||
await this.assertIrnBelongsToInvoice(irn, invoice.eimsDocumentNumber);
|
||||
}
|
||||
|
||||
// Same source of truth as registration: the state row is keyed by the token's system number.
|
||||
@@ -266,6 +297,7 @@ export class EimsInvoiceRegistrationService {
|
||||
...(irn ? { previousIrn: irn } : {}),
|
||||
inFlightInvoiceId: null,
|
||||
inFlightCounter: null,
|
||||
inFlightDocumentNumber: null,
|
||||
blockedReason: null,
|
||||
});
|
||||
});
|
||||
@@ -311,23 +343,27 @@ export class EimsInvoiceRegistrationService {
|
||||
if (invoice.eimsIrn) return null;
|
||||
|
||||
const invoiceCounter = Number(state.nextInvoiceCounter);
|
||||
const documentNumber = String(Number(state.nextDocumentNumber));
|
||||
const previousIrn = state.previousIrn ?? "";
|
||||
|
||||
// Counter consumed here, not on success: once an attempt begins it can never be reused,
|
||||
// whatever happens next. A gap is harmless at MoR; a collision is not.
|
||||
await manager.update(EimsSystemState, state.id, {
|
||||
nextInvoiceCounter: invoiceCounter + 1,
|
||||
nextDocumentNumber: Number(documentNumber) + 1,
|
||||
inFlightInvoiceId: invoiceId,
|
||||
inFlightCounter: invoiceCounter,
|
||||
inFlightDocumentNumber: Number(documentNumber),
|
||||
});
|
||||
await manager.update(Invoice, invoiceId, {
|
||||
eimsStatus: EimsInvoiceStatus.Submitting,
|
||||
eimsInvoiceCounter: invoiceCounter,
|
||||
eimsDocumentNumber: documentNumber,
|
||||
eimsSubmittedAt: new Date(),
|
||||
eimsLastError: null,
|
||||
});
|
||||
|
||||
return { stateId: state.id, invoiceCounter, previousIrn };
|
||||
return { stateId: state.id, invoiceCounter, documentNumber, previousIrn };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -350,15 +386,26 @@ export class EimsInvoiceRegistrationService {
|
||||
previousIrn: irn,
|
||||
inFlightInvoiceId: null,
|
||||
inFlightCounter: null,
|
||||
inFlightDocumentNumber: null,
|
||||
blockedReason: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* TX2b. A deterministic rejection releases the reservation; an ambiguous result keeps it and
|
||||
* blocks the system number, because `PreviousIrn` is now unknown for every later document.
|
||||
* The counter stays consumed either way.
|
||||
* TX2b. A deterministic rejection releases the reservation **and returns the counter**; an
|
||||
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
|
||||
* for every later document.
|
||||
*
|
||||
* The two numbers move differently, because MoR constrains them differently:
|
||||
*
|
||||
* - `InvoiceCounter` must not **skip** — "Invoice counter is not correct. expected : 1". A
|
||||
* document MoR definitively refused was never counted there, so ours must not advance either.
|
||||
* - `DocumentNumber` must not **repeat** — the documented rule is "Document number is not
|
||||
* unique". It is therefore spent by the attempt itself and never handed back, even for a
|
||||
* refusal.
|
||||
*
|
||||
* An ambiguous result keeps both: MoR may have counted and stored the document.
|
||||
*/
|
||||
private async settleFailure(
|
||||
invoiceId: string,
|
||||
@@ -386,7 +433,15 @@ export class EimsInvoiceRegistrationService {
|
||||
EimsSystemState,
|
||||
reservation.stateId,
|
||||
deterministic
|
||||
? { inFlightInvoiceId: null, inFlightCounter: null, blockedReason: null }
|
||||
? {
|
||||
// Counter returns (MoR never counted a refused document); the document number does
|
||||
// not (MoR requires it to be unique, so it is burned by the attempt).
|
||||
nextInvoiceCounter: reservation.invoiceCounter,
|
||||
inFlightInvoiceId: null,
|
||||
inFlightCounter: null,
|
||||
inFlightDocumentNumber: null,
|
||||
blockedReason: null,
|
||||
}
|
||||
: {
|
||||
blockedReason:
|
||||
`Invoice ${invoiceId} was submitted with counter ${reservation.invoiceCounter} but ` +
|
||||
@@ -397,6 +452,41 @@ export class EimsInvoiceRegistrationService {
|
||||
});
|
||||
|
||||
this.logger.error(`Invoice ${invoiceId} EIMS registration ${status}: ${lastError.message}`);
|
||||
await this.alertStaff(invoiceId, status, lastError, deterministic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the people who can act about a failed filing.
|
||||
*
|
||||
* An ambiguous result is the urgent one: it blocks *every* further invoice for this system
|
||||
* number until a human resolves it, and nothing else in the system would surface that — the
|
||||
* sweep just goes quiet. A deterministic rejection affects one invoice, so it is normal
|
||||
* priority. Never throws: an alert that fails must not mask the filing outcome.
|
||||
*/
|
||||
private async alertStaff(
|
||||
invoiceId: string,
|
||||
status: EimsInvoiceStatus,
|
||||
error: EimsInvoiceError,
|
||||
deterministic: boolean,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { permissionKeys: [FREIGHT_PERMS.invoices.eimsResolve] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.GENERIC,
|
||||
priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH,
|
||||
title: deterministic
|
||||
? "EIMS rejected an invoice"
|
||||
: "EIMS filing unresolved — all further filing is blocked",
|
||||
body: deterministic
|
||||
? `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.`
|
||||
: `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`,
|
||||
link: `/dashboard/invoices/${invoiceId}`,
|
||||
data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" },
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`EIMS staff alert failed for invoice ${invoiceId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -488,6 +578,7 @@ export class EimsInvoiceRegistrationService {
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
|
||||
eimsIrn: invoice.eimsIrn ?? null,
|
||||
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
|
||||
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
|
||||
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
|
||||
eimsAckDate: invoice.eimsAckDate ?? null,
|
||||
|
||||
@@ -80,6 +80,8 @@ export interface EimsInvoiceStatusView {
|
||||
invoiceNumber: string;
|
||||
eimsStatus: EimsInvoiceStatus;
|
||||
eimsIrn: string | null;
|
||||
/** The numeric DocumentNumber filed with MoR; not our own invoiceNumber. */
|
||||
eimsDocumentNumber: string | null;
|
||||
eimsInvoiceCounter: number | null;
|
||||
eimsSubmittedAt: Date | null;
|
||||
eimsAckDate: string | null;
|
||||
|
||||
@@ -33,6 +33,8 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
|
||||
paymentTerm: "IMMIDIATE",
|
||||
unitDefault: "PCS",
|
||||
buyerCountryCode: null,
|
||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
|
||||
cashierName: null,
|
||||
salesPersonName: null,
|
||||
...over,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
@@ -22,6 +23,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
imports: [
|
||||
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
|
||||
TypeOrmModule.forFeature([EimsSystemState, Invoice]),
|
||||
NotificationInboxModule,
|
||||
],
|
||||
controllers: [EimsInvoiceController],
|
||||
providers: [
|
||||
|
||||
@@ -18,6 +18,18 @@ export class EimsSystemState extends BaseEntity {
|
||||
@Column({ name: "next_invoice_counter", type: "bigint", default: 1 })
|
||||
nextInvoiceCounter!: number;
|
||||
|
||||
/**
|
||||
* `DocumentDetails.DocumentNumber` for the next registration.
|
||||
*
|
||||
* Separate from our own `invoiceNumber`, which MoR cannot accept: it validates the field against
|
||||
* `^(0|[1-9][0-9]{0,8})$`, a plain integer.
|
||||
*/
|
||||
@Column({ name: "next_document_number", type: "bigint", default: 1 })
|
||||
nextDocumentNumber!: number;
|
||||
|
||||
@Column({ name: "in_flight_document_number", type: "bigint", nullable: true })
|
||||
inFlightDocumentNumber?: number | null;
|
||||
|
||||
/** IRN of the last successful registration; null until the first one succeeds. */
|
||||
@Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true })
|
||||
previousIrn?: string | null;
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNumber, Min } from 'class-validator';
|
||||
import { IsNumber, IsOptional, Min } from 'class-validator';
|
||||
|
||||
export class ApproveLastMileRequestDto {
|
||||
// The approve dialog prefills this from GET :id/price-estimate (rule-based),
|
||||
// but the chief can still override — the typed value is what's invoiced.
|
||||
@ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 })
|
||||
@Transform(({ value }) => Number(value))
|
||||
// Omitted = the rule-based last-mile rate estimate is the advance. The chief
|
||||
// can still override with an explicit amount (required when no rate covers
|
||||
// the job).
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Advance override. Omitted = the amount comes from the live last-mile rates (km × rate).',
|
||||
example: 3000,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value)))
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
advanceAmount!: number;
|
||||
advanceAmount?: number;
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ export class LastMileRequestsController {
|
||||
|
||||
@Post(':id/approve')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
|
||||
@ApiOperation({ summary: 'Truck & Machinery chief approves the request — LM contract becomes signable; the advance invoice follows the customer signature' })
|
||||
@ApiOperation({ summary: 'Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature' })
|
||||
approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ApproveLastMileRequestDto,
|
||||
|
||||
@@ -299,7 +299,11 @@ export class LastMileRequestsService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async approve(id: string, staffId: string | null, advanceAmount: number): Promise<LastMileRequest> {
|
||||
async approve(
|
||||
id: string,
|
||||
staffId: string | null,
|
||||
advanceOverride?: number | null,
|
||||
): Promise<LastMileRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== LastMileRequestStatus.Submitted) {
|
||||
throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`);
|
||||
@@ -307,6 +311,18 @@ export class LastMileRequestsService {
|
||||
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
|
||||
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
|
||||
|
||||
// The live last-mile rates are the authority on the advance (km × rate);
|
||||
// the chief's typed amount is only an override — and the only path when no
|
||||
// rate covers the job. Snapshotted so the contract and invoice stay immune
|
||||
// to later rate edits.
|
||||
const estimate = await this.priceEstimate(id);
|
||||
const advanceAmount = advanceOverride ?? estimate.total;
|
||||
if (!advanceAmount || advanceAmount <= 0) {
|
||||
throw new BadRequestException(
|
||||
'No live last-mile rate covers this job — enter the advance amount manually.',
|
||||
);
|
||||
}
|
||||
|
||||
// Idempotent per booking — reuses the record if one already exists.
|
||||
const lastMile = await this.lastMileService.create({
|
||||
bookingId: request.bookingId,
|
||||
@@ -316,10 +332,6 @@ export class LastMileRequestsService {
|
||||
|
||||
// No invoice yet: the advance is invoiced by LastMileContractService.sign()
|
||||
// once the customer has signed the LM contract — doc first, then payment.
|
||||
// Snapshot the rate estimate now so the contract shows the numbers the
|
||||
// chief actually approved against, immune to later rate edits.
|
||||
const estimate = await this.priceEstimate(id);
|
||||
|
||||
await this.requestsRepository.update(id, {
|
||||
status: LastMileRequestStatus.Approved,
|
||||
reviewedByStaffId: staffId,
|
||||
@@ -364,7 +376,10 @@ export class LastMileRequestsService {
|
||||
type: 'LAST_MILE_ADVANCE',
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId || '',
|
||||
currency: booking.paymentCurrency || 'ETB',
|
||||
// The advance is priced by the last-mile rate, so it bills in that
|
||||
// rate's currency (birr for domestic trucking) — the booking's payment
|
||||
// currency is only the fallback when the amount was a manual override.
|
||||
currency: request.contractSummary?.currency || booking.paymentCurrency || 'ETB',
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'LAST_MILE_ADVANCE',
|
||||
|
||||
@@ -60,14 +60,38 @@ export class RefundDto {
|
||||
}
|
||||
|
||||
export class ClientActionDto {
|
||||
// INVOKE_BRIDGE (SuperApp mini-app payload) is part of the shared ClientAction union and so
|
||||
// must be assignable here, but freight never requests platform=inapp and therefore never
|
||||
// receives one. Passenger owns that flow — see docs/telebirr-miniapp/.
|
||||
@ApiProperty({
|
||||
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
|
||||
enum: [
|
||||
"REDIRECT",
|
||||
"LAUNCH_APP",
|
||||
"INVOKE_BRIDGE",
|
||||
"COLLECT_OTP",
|
||||
"SHOW_BILL_REFERENCE",
|
||||
],
|
||||
})
|
||||
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
|
||||
type!:
|
||||
| "REDIRECT"
|
||||
| "LAUNCH_APP"
|
||||
| "INVOKE_BRIDGE"
|
||||
| "COLLECT_OTP"
|
||||
| "SHOW_BILL_REFERENCE";
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||
url?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight",
|
||||
})
|
||||
bridge?: "TELEBIRR";
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight",
|
||||
})
|
||||
rawRequest?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
|
||||
appId?: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { SUPPORT_MEDIA_PREFIX, SupportDocSlug } from "@edr/types";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
/**
|
||||
* Markdown bodies are safe on read — the portal renders them with
|
||||
* `react-markdown` and no `rehype-raw`, so any HTML in them is inert. The
|
||||
* fields worth validating are these: they land in `href`/`src` attributes and
|
||||
* bypass markdown entirely, which is where a `javascript:` URL would actually
|
||||
* execute.
|
||||
*
|
||||
* Placeholders survive the check because they sit after the scheme
|
||||
* (`mailto:{{supportEmail}}`, `tel:{{supportPhoneTel}}`).
|
||||
*/
|
||||
const LINK_PATTERN = /^(https?:\/\/|mailto:|tel:|\/)/;
|
||||
const LINK_MESSAGE =
|
||||
"$property must start with http(s)://, mailto:, tel: or /";
|
||||
|
||||
/**
|
||||
* A media source is either an uploaded MinIO object key, a same-origin path, or
|
||||
* an https URL. Anything else — notably `javascript:` — is refused, since this
|
||||
* value lands in an `<img>`/`<video>` src.
|
||||
*/
|
||||
const MEDIA_SRC_PATTERN = new RegExp(
|
||||
`^(https?:\\/\\/|\\/|${SUPPORT_MEDIA_PREFIX.replace("/", "\\/")})`,
|
||||
);
|
||||
const MEDIA_SRC_MESSAGE =
|
||||
`$property must be an uploaded ${SUPPORT_MEDIA_PREFIX} key, a /path, or an http(s):// URL`;
|
||||
|
||||
/* ------------------------------- CONTACT ------------------------------- */
|
||||
|
||||
export class PortalSupportContactDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(200)
|
||||
email!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(60)
|
||||
phone!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
office!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
hours!: string;
|
||||
}
|
||||
|
||||
/* ---------------------------- PRIVACY / TERMS --------------------------- */
|
||||
|
||||
export class PortalDocSectionDto {
|
||||
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
id?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
heading!: string;
|
||||
|
||||
@ApiProperty({ description: "Markdown" })
|
||||
@IsString()
|
||||
@MaxLength(20_000)
|
||||
body!: string;
|
||||
}
|
||||
|
||||
export class PortalLegalContentDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
subtitle!: string;
|
||||
|
||||
@ApiProperty({ description: 'Free text, e.g. "6 August 2026"' })
|
||||
@IsString()
|
||||
@MaxLength(60)
|
||||
lastUpdated!: string;
|
||||
|
||||
@ApiProperty({ type: [PortalDocSectionDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(60)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PortalDocSectionDto)
|
||||
sections!: PortalDocSectionDto[];
|
||||
}
|
||||
|
||||
/* --------------------------------- FAQ --------------------------------- */
|
||||
|
||||
export class PortalCtaCardDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
heading!: string;
|
||||
|
||||
@ApiProperty({ description: "Markdown" })
|
||||
@IsString()
|
||||
@MaxLength(2_000)
|
||||
body!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
ctaLabel!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@Matches(LINK_PATTERN, { message: LINK_MESSAGE })
|
||||
@MaxLength(500)
|
||||
ctaTo!: string;
|
||||
}
|
||||
|
||||
export class PortalFaqItemDto {
|
||||
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
id?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(300)
|
||||
question!: string;
|
||||
|
||||
@ApiProperty({ description: "Markdown" })
|
||||
@IsString()
|
||||
@MaxLength(5_000)
|
||||
answer!: string;
|
||||
}
|
||||
|
||||
export class PortalFaqGroupDto {
|
||||
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
id?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ type: [PortalFaqItemDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PortalFaqItemDto)
|
||||
items!: PortalFaqItemDto[];
|
||||
}
|
||||
|
||||
export class PortalFaqContentDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
subtitle!: string;
|
||||
|
||||
@ApiProperty({ type: [PortalFaqGroupDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PortalFaqGroupDto)
|
||||
groups!: PortalFaqGroupDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: PortalCtaCardDto, nullable: true })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => PortalCtaCardDto)
|
||||
footer?: PortalCtaCardDto | null;
|
||||
}
|
||||
|
||||
/* --------------------------------- HELP -------------------------------- */
|
||||
|
||||
export class PortalMediaDto {
|
||||
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
id?: string;
|
||||
|
||||
@ApiProperty({ enum: ["image", "video"] })
|
||||
@IsIn(["image", "video"])
|
||||
kind!: "image" | "video";
|
||||
|
||||
@ApiProperty({
|
||||
description: `An uploaded ${SUPPORT_MEDIA_PREFIX} key, a same-origin /path, or an https:// URL`,
|
||||
})
|
||||
@IsString()
|
||||
@Matches(MEDIA_SRC_PATTERN, { message: MEDIA_SRC_MESSAGE })
|
||||
@MaxLength(500)
|
||||
src!: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
caption?: string | null;
|
||||
}
|
||||
|
||||
export class PortalHelpSectionDto {
|
||||
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
id?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
heading!: string;
|
||||
|
||||
@ApiProperty({ description: "Markdown" })
|
||||
@IsString()
|
||||
@MaxLength(20_000)
|
||||
body!: string;
|
||||
|
||||
@ApiProperty({ type: [PortalMediaDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(12)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PortalMediaDto)
|
||||
media!: PortalMediaDto[];
|
||||
}
|
||||
|
||||
export class PortalHelpContentDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
subtitle!: string;
|
||||
|
||||
@ApiProperty({ type: [PortalHelpSectionDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(40)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PortalHelpSectionDto)
|
||||
sections!: PortalHelpSectionDto[];
|
||||
}
|
||||
|
||||
/* ------------------------------- request ------------------------------- */
|
||||
|
||||
export class UpdateSupportDocumentDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"The document's whole payload. Validated against the shape for its slug.",
|
||||
type: Object,
|
||||
})
|
||||
@IsObject()
|
||||
payload!: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ description: "Why this change was made" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Which DTO class a slug's payload is validated against on write. */
|
||||
export const PAYLOAD_DTO_BY_SLUG: Record<
|
||||
SupportDocSlug,
|
||||
new () => object
|
||||
> = {
|
||||
CONTACT: PortalSupportContactDto,
|
||||
HELP: PortalHelpContentDto,
|
||||
FAQ: PortalFaqContentDto,
|
||||
PRIVACY: PortalLegalContentDto,
|
||||
TERMS: PortalLegalContentDto,
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import type { SupportDocPayload, SupportDocSlug } from "@edr/types";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
/**
|
||||
* One editable customer-facing document for the freight portal's public pages
|
||||
* (/help, /faq, /terms, /privacy) plus the support contact block they all
|
||||
* quote. Five fixed rows, keyed by slug and seeded on first boot — there is no
|
||||
* create/delete route.
|
||||
*
|
||||
* The payload is opaque jsonb because the five documents have genuinely
|
||||
* different shapes and the help page's blocks keep changing; typed columns
|
||||
* would mean a migration per copy tweak. Shape safety lives in the per-slug
|
||||
* DTOs the service validates against on write, the same arrangement
|
||||
* `contract_templates.articles` already uses.
|
||||
*
|
||||
* `version` is the live row's version and always equals `max(version)` in
|
||||
* {@link SupportDocumentVersion} — the seeder writes v1 and its history row
|
||||
* together, so the log is never missing the current state.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "support_documents" })
|
||||
@Index(["slug"], { unique: true })
|
||||
export class SupportDocument extends BaseEntity {
|
||||
@Column({ name: "slug", type: "varchar", length: 32, unique: true })
|
||||
slug!: SupportDocSlug;
|
||||
|
||||
@Column({ name: "payload", type: "jsonb", default: () => "'{}'::jsonb" })
|
||||
payload!: SupportDocPayload;
|
||||
|
||||
@Column({ name: "version", type: "int", default: 1 })
|
||||
version!: number;
|
||||
|
||||
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
||||
updatedById?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append-only history of every saved state of a document, written in the same
|
||||
* transaction as the live row. Restoring is not a pointer reset: it re-saves an
|
||||
* old payload through the normal write path, producing a *new* version, so a
|
||||
* restore is itself undoable and the log only ever grows.
|
||||
*
|
||||
* Modelled on {@link CompanyRevision}, but snapshots the whole payload rather
|
||||
* than a field diff — rollback needs the full state, not the delta.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "support_document_versions" })
|
||||
@Index(["documentId"])
|
||||
export class SupportDocumentVersion extends BaseEntity {
|
||||
@Column({ name: "document_id", type: "uuid" })
|
||||
documentId!: string;
|
||||
|
||||
@ManyToOne(() => SupportDocument, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "document_id" })
|
||||
document?: SupportDocument;
|
||||
|
||||
@Column({ name: "version", type: "int" })
|
||||
version!: number;
|
||||
|
||||
@Column({ name: "payload", type: "jsonb" })
|
||||
payload!: SupportDocPayload;
|
||||
|
||||
/** Who saved it. Null for the seeded initial version. */
|
||||
@Column({ name: "actor_id", type: "uuid", nullable: true })
|
||||
actorId?: string | null;
|
||||
|
||||
/** Optional "why" the editor typed, shown in the history list. */
|
||||
@Column({ name: "note", type: "varchar", length: 255, nullable: true })
|
||||
note?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Public } from "@edr/api-common";
|
||||
import { Controller, Get, Header } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { SupportContentService } from "./support-content.service";
|
||||
|
||||
/**
|
||||
* The portal's /help, /faq, /terms and /privacy routes are deliberately outside
|
||||
* its auth guard — the sign-up screen links to them before a session exists —
|
||||
* so this read must work with no token. `@Public()` is class-level because that
|
||||
* is the idiom the other genuinely-anonymous controllers here use; without it
|
||||
* the globally registered `JwtGuard` would 401 every anonymous visitor.
|
||||
*/
|
||||
@ApiTags("support-content")
|
||||
@Public()
|
||||
@Controller("support-content")
|
||||
export class PublicSupportContentController {
|
||||
constructor(private readonly service: SupportContentService) {}
|
||||
|
||||
@Get()
|
||||
// Hit on every anonymous page view, and the copy changes a few times a year.
|
||||
@Header("Cache-Control", "public, max-age=300")
|
||||
@ApiOperation({
|
||||
summary: "Public help, FAQ, legal and support-contact copy for the portal",
|
||||
})
|
||||
getBundle() {
|
||||
return this.service.getBundle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { SUPPORT_MEDIA_MAX_BYTES } from "@edr/types";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { UpdateSupportDocumentDto } from "./dto/support-content.dto";
|
||||
import { SupportContentService } from "./support-content.service";
|
||||
|
||||
const READ = [
|
||||
FREIGHT_PERMS.settings.supportContent.view,
|
||||
FREIGHT_PERMS.settings.supportContent.manage,
|
||||
FREIGHT_PERMS.admin,
|
||||
];
|
||||
|
||||
const WRITE = [
|
||||
FREIGHT_PERMS.settings.supportContent.manage,
|
||||
FREIGHT_PERMS.admin,
|
||||
];
|
||||
|
||||
@ApiTags("support-content")
|
||||
@ApiBearerAuth()
|
||||
@Controller("support-content")
|
||||
export class SupportContentController {
|
||||
constructor(private readonly service: SupportContentService) {}
|
||||
|
||||
/**
|
||||
* Stores a help-page image or video and returns its object key. The editor
|
||||
* saves the key, not the returned URL — see `SupportContentService.uploadMedia`.
|
||||
*
|
||||
* The Multer limit duplicates the service-side type check on purpose: it
|
||||
* stops reading the socket once the part is oversized instead of buffering
|
||||
* the whole thing into memory first.
|
||||
*/
|
||||
@Post("media")
|
||||
@BookingStaff(WRITE)
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", { limits: { fileSize: SUPPORT_MEDIA_MAX_BYTES } }),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Upload an image or video for a help section" })
|
||||
uploadMedia(@UploadedFile() file: Express.Multer.File) {
|
||||
return this.service.uploadMedia(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs one stored key so the editor can preview an already-saved image.
|
||||
* The editor stores `minio:<key>`, never a signed URL, so it needs somewhere
|
||||
* to resolve those refs for display — this is it.
|
||||
*/
|
||||
@Get("media-url")
|
||||
@BookingStaff(READ)
|
||||
@ApiQuery({ name: "key", description: "MinIO object key" })
|
||||
@ApiOperation({ summary: "Presigned URL for one stored media key" })
|
||||
mediaUrl(@Query("key") key: string) {
|
||||
return this.service.mediaUrl(key);
|
||||
}
|
||||
|
||||
@Get("documents")
|
||||
@BookingStaff(READ)
|
||||
@ApiOperation({ summary: "List the five portal content documents (no payloads)" })
|
||||
list() {
|
||||
return this.service.list();
|
||||
}
|
||||
|
||||
@Get("documents/:slug")
|
||||
@BookingStaff(READ)
|
||||
@ApiOperation({ summary: "Get one document with its payload" })
|
||||
getBySlug(@Param("slug") slug: string) {
|
||||
return this.service.getBySlug(slug);
|
||||
}
|
||||
|
||||
@Patch("documents/:slug")
|
||||
@BookingStaff(WRITE)
|
||||
@ApiOperation({
|
||||
summary: "Replace a document's payload, recording a new version",
|
||||
})
|
||||
update(
|
||||
@Param("slug") slug: string,
|
||||
@Body() dto: UpdateSupportDocumentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.update(slug, dto, user?.id ?? null);
|
||||
}
|
||||
|
||||
@Get("documents/:slug/versions")
|
||||
@BookingStaff(READ)
|
||||
@ApiOperation({ summary: "Version history, newest first (no payloads)" })
|
||||
listVersions(@Param("slug") slug: string) {
|
||||
return this.service.listVersions(slug);
|
||||
}
|
||||
|
||||
@Get("documents/:slug/versions/:version")
|
||||
@BookingStaff(READ)
|
||||
@ApiOperation({ summary: "One historical version, with its payload" })
|
||||
getVersion(
|
||||
@Param("slug") slug: string,
|
||||
@Param("version", ParseIntPipe) version: number,
|
||||
) {
|
||||
return this.service.getVersion(slug, version);
|
||||
}
|
||||
|
||||
@Post("documents/:slug/versions/:version/restore")
|
||||
@BookingStaff(WRITE)
|
||||
@ApiOperation({
|
||||
summary: "Restore a version — re-saves it as a new version, never destructive",
|
||||
})
|
||||
restore(
|
||||
@Param("slug") slug: string,
|
||||
@Param("version", ParseIntPipe) version: number,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.restore(slug, version, user?.id ?? null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import {
|
||||
SupportDocument,
|
||||
SupportDocumentVersion,
|
||||
} from "./entities/support-document.entity";
|
||||
import { PublicSupportContentController } from "./public-support-content.controller";
|
||||
import { SupportContentController } from "./support-content.controller";
|
||||
import { SupportContentRepository } from "./support-content.repository";
|
||||
import { SupportContentService } from "./support-content.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([SupportDocument, SupportDocumentVersion]),
|
||||
MinioModule,
|
||||
],
|
||||
controllers: [PublicSupportContentController, SupportContentController],
|
||||
providers: [SupportContentRepository, SupportContentService],
|
||||
exports: [SupportContentService],
|
||||
})
|
||||
export class SupportContentModule {}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import type { SupportDocSlug } from "@edr/types";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import {
|
||||
SupportDocument,
|
||||
SupportDocumentVersion,
|
||||
} from "./entities/support-document.entity";
|
||||
|
||||
@Injectable()
|
||||
export class SupportContentRepository extends BaseRepository<SupportDocument> {
|
||||
constructor(
|
||||
@InjectRepository(SupportDocument)
|
||||
repository: Repository<SupportDocument>,
|
||||
@InjectRepository(SupportDocumentVersion)
|
||||
private readonly versions: Repository<SupportDocumentVersion>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findBySlug(slug: SupportDocSlug): Promise<SupportDocument | null> {
|
||||
return this.repository.findOne({ where: { slug } });
|
||||
}
|
||||
|
||||
override findAll(): Promise<SupportDocument[]> {
|
||||
return this.repository.find({ order: { slug: "ASC" } });
|
||||
}
|
||||
|
||||
findVersions(documentId: string): Promise<SupportDocumentVersion[]> {
|
||||
return this.versions.find({
|
||||
where: { documentId },
|
||||
order: { version: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
findVersion(
|
||||
documentId: string,
|
||||
version: number,
|
||||
): Promise<SupportDocumentVersion | null> {
|
||||
return this.versions.findOne({ where: { documentId, version } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump the live row and append its history entry atomically. Doing both in
|
||||
* one transaction is what guarantees `document.version` always has a matching
|
||||
* version row — the invariant the history list and rollback both rely on.
|
||||
*/
|
||||
async saveWithVersion(
|
||||
document: SupportDocument,
|
||||
actorId: string | null,
|
||||
note: string | null,
|
||||
): Promise<SupportDocument> {
|
||||
return this.repository.manager.transaction(async (manager) => {
|
||||
const saved = await manager.save(SupportDocument, document);
|
||||
|
||||
await manager.save(
|
||||
manager.create(SupportDocumentVersion, {
|
||||
documentId: saved.id,
|
||||
version: saved.version,
|
||||
payload: saved.payload,
|
||||
actorId,
|
||||
note,
|
||||
}),
|
||||
);
|
||||
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
SUPPORT_CONTENT_DEFAULTS,
|
||||
SUPPORT_DOC_SLUGS,
|
||||
SupportDocPayload,
|
||||
SupportDocSlug,
|
||||
} from "@edr/types";
|
||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import {
|
||||
SupportDocument,
|
||||
SupportDocumentVersion,
|
||||
} from "./entities/support-document.entity";
|
||||
import { SupportContentRepository } from "./support-content.repository";
|
||||
import { SupportContentService, validatePayload } from "./support-content.service";
|
||||
|
||||
const ACTOR = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
/**
|
||||
* In-memory stand-in for the repository: one seeded document plus its version
|
||||
* log, with `saveWithVersion` doing what the real transaction does. Enough to
|
||||
* assert the rollback contract without a database.
|
||||
*/
|
||||
function makeRepository(slug: SupportDocSlug) {
|
||||
const document = {
|
||||
id: "00000000-0000-0000-0000-000000000001",
|
||||
slug,
|
||||
payload: SUPPORT_CONTENT_DEFAULTS[slug],
|
||||
version: 1,
|
||||
updatedById: null,
|
||||
} as SupportDocument;
|
||||
|
||||
const versions: SupportDocumentVersion[] = [
|
||||
{
|
||||
id: "v1",
|
||||
documentId: document.id,
|
||||
version: 1,
|
||||
payload: document.payload,
|
||||
actorId: null,
|
||||
note: "Initial content",
|
||||
} as SupportDocumentVersion,
|
||||
];
|
||||
|
||||
const repository = {
|
||||
findAll: jest.fn(async () => [document]),
|
||||
findBySlug: jest.fn(async (s: SupportDocSlug) =>
|
||||
s === slug ? document : null,
|
||||
),
|
||||
findVersions: jest.fn(async () => [...versions].reverse()),
|
||||
findVersion: jest.fn(
|
||||
async (_id: string, version: number) =>
|
||||
versions.find((v) => v.version === version) ?? null,
|
||||
),
|
||||
saveWithVersion: jest.fn(
|
||||
async (doc: SupportDocument, actorId: string | null, note: string | null) => {
|
||||
versions.push({
|
||||
id: `v${doc.version}`,
|
||||
documentId: doc.id,
|
||||
version: doc.version,
|
||||
payload: doc.payload,
|
||||
actorId,
|
||||
note,
|
||||
} as SupportDocumentVersion);
|
||||
return doc;
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
// Signing is exercised only through getBundle; the write path never touches it.
|
||||
const minio = {
|
||||
getSignedUrl: jest.fn(async (key: string) => `https://minio.test/${key}?sig=x`),
|
||||
uploadFile: jest.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
document,
|
||||
versions,
|
||||
minio,
|
||||
service: new SupportContentService(
|
||||
repository as unknown as SupportContentRepository,
|
||||
minio as unknown as MinioService,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("SupportContentService.restore", () => {
|
||||
it("rolls back to an older payload as a NEW version, leaving history intact", async () => {
|
||||
const { document, versions, service } = makeRepository("CONTACT");
|
||||
const original = SUPPORT_CONTENT_DEFAULTS.CONTACT;
|
||||
|
||||
await service.update(
|
||||
"CONTACT",
|
||||
{ payload: { ...original, phone: "+251 99 999 9999" } },
|
||||
ACTOR,
|
||||
);
|
||||
|
||||
expect(document.version).toBe(2);
|
||||
expect(versions.map((v) => v.version)).toEqual([1, 2]);
|
||||
|
||||
const restored = await service.restore("CONTACT", 1, ACTOR);
|
||||
|
||||
expect(restored.payload).toEqual(original);
|
||||
// The assertion that matters: restore is additive. If someone "optimises"
|
||||
// it into a destructive pointer reset this drops back to 1 and rollback
|
||||
// silently stops being undoable.
|
||||
expect(restored.version).toBe(3);
|
||||
expect(versions.map((v) => v.version)).toEqual([1, 2, 3]);
|
||||
expect(versions[2].note).toBe("Restored version 1");
|
||||
});
|
||||
|
||||
it("404s on a version that was never written", async () => {
|
||||
const { service } = makeRepository("CONTACT");
|
||||
await expect(service.restore("CONTACT", 99, ACTOR)).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unknown slug", async () => {
|
||||
const { service } = makeRepository("CONTACT");
|
||||
await expect(service.getBySlug("NOPE")).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SupportContentService.getBundle", () => {
|
||||
it("signs stored keys on read and leaves paths and URLs alone", async () => {
|
||||
const { service, minio } = makeRepository("CONTACT");
|
||||
const help = SUPPORT_CONTENT_DEFAULTS.HELP;
|
||||
|
||||
// findAll returns only CONTACT, so HELP falls back to the defaults — which
|
||||
// is itself worth asserting: an unseeded row must not break the page.
|
||||
const bundle = await service.getBundle();
|
||||
expect(bundle.help.sections[0].media[0].src).toBe(
|
||||
"/assets/edr-portal-guide.webm",
|
||||
);
|
||||
expect(minio.getSignedUrl).not.toHaveBeenCalled();
|
||||
|
||||
const withUpload = {
|
||||
...help,
|
||||
sections: [
|
||||
{
|
||||
...help.sections[0],
|
||||
body: "See  below.",
|
||||
media: [
|
||||
{ id: "m1", kind: "image" as const, src: "support-content/a.png" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const signed = await (service as any).signMedia({
|
||||
...bundle,
|
||||
help: withUpload,
|
||||
});
|
||||
|
||||
expect(signed.help.sections[0].media[0].src).toBe(
|
||||
"https://minio.test/support-content/a.png?sig=x",
|
||||
);
|
||||
expect(signed.help.sections[0].body).toContain(
|
||||
"https://minio.test/support-content/d.png?sig=x",
|
||||
);
|
||||
// The stored copy must never be mutated into a URL — that is what would rot.
|
||||
expect(withUpload.sections[0].media[0].src).toBe("support-content/a.png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePayload", () => {
|
||||
const help = SUPPORT_CONTENT_DEFAULTS.HELP;
|
||||
|
||||
it("accepts every shipped default", () => {
|
||||
for (const slug of SUPPORT_DOC_SLUGS) {
|
||||
expect(() =>
|
||||
validatePayload(slug, SUPPORT_CONTENT_DEFAULTS[slug]),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
const sectionWithMedia = (src: string) => ({
|
||||
...help,
|
||||
sections: [{ ...help.sections[0], media: [{ kind: "video", src }] }],
|
||||
});
|
||||
|
||||
it("rejects a javascript: media source", () => {
|
||||
// The markdown renderer drops raw HTML, so src attributes like this one are
|
||||
// the only place a script URL could still execute.
|
||||
expect(() =>
|
||||
validatePayload("HELP", sectionWithMedia("javascript:alert(1)")),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("accepts an uploaded key, a rooted path and an https URL", () => {
|
||||
for (const src of [
|
||||
"support-content/9f1c.png",
|
||||
"/assets/edr-portal-guide.webm",
|
||||
"https://cdn.example.com/clip.mp4",
|
||||
]) {
|
||||
expect(() => validatePayload("HELP", sectionWithMedia(src))).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a media kind that is neither image nor video", () => {
|
||||
expect(() =>
|
||||
validatePayload("HELP", {
|
||||
...help,
|
||||
sections: [
|
||||
{
|
||||
...help.sections[0],
|
||||
media: [{ kind: "pdf", src: "support-content/a.pdf" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("keeps placeholder links, which sit after the scheme", () => {
|
||||
expect(() =>
|
||||
validatePayload("FAQ", {
|
||||
...SUPPORT_CONTENT_DEFAULTS.FAQ,
|
||||
footer: {
|
||||
...SUPPORT_CONTENT_DEFAULTS.FAQ.footer!,
|
||||
ctaTo: "mailto:{{supportEmail}}",
|
||||
},
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("generates ids for items saved without one", () => {
|
||||
const legal = validatePayload("TERMS", {
|
||||
...SUPPORT_CONTENT_DEFAULTS.TERMS,
|
||||
sections: [{ heading: "1. New", body: "Body." }],
|
||||
}) as Extract<SupportDocPayload, { sections: unknown }>;
|
||||
|
||||
expect(legal.sections[0].id).toEqual(expect.any(String));
|
||||
expect(legal.sections[0].id.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
import {
|
||||
PORTAL_MEDIA_URI_SCHEME,
|
||||
PortalContentBundle,
|
||||
PortalFaqContent,
|
||||
PortalHelpContent,
|
||||
PortalLegalContent,
|
||||
PortalMediaKind,
|
||||
SUPPORT_CONTENT_DEFAULTS,
|
||||
SUPPORT_DOC_SLUGS,
|
||||
SUPPORT_MEDIA_PREFIX,
|
||||
SupportDocPayload,
|
||||
SupportDocSlug,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { extname } from "path";
|
||||
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { validateSync, ValidationError } from "class-validator";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
import {
|
||||
PAYLOAD_DTO_BY_SLUG,
|
||||
UpdateSupportDocumentDto,
|
||||
} from "./dto/support-content.dto";
|
||||
import {
|
||||
SupportDocument,
|
||||
SupportDocumentVersion,
|
||||
} from "./entities/support-document.entity";
|
||||
import { SupportContentRepository } from "./support-content.repository";
|
||||
|
||||
/**
|
||||
* A paste-bomb in one section would bloat the public bundle every anonymous
|
||||
* visitor downloads, so the whole payload is capped as well as its fields.
|
||||
*/
|
||||
const MAX_PAYLOAD_BYTES = 200_000;
|
||||
|
||||
/**
|
||||
* Comfortably longer than the 5-minute `Cache-Control` on the public bundle, so
|
||||
* a cached response never outlives the URLs inside it.
|
||||
*/
|
||||
const MEDIA_URL_TTL_SECONDS = 6 * 60 * 60;
|
||||
|
||||
/** `minio:support-content/<file>` inside markdown. */
|
||||
const MEDIA_REF = new RegExp(
|
||||
`${PORTAL_MEDIA_URI_SCHEME}([A-Za-z0-9._\\-/]+)`,
|
||||
"g",
|
||||
);
|
||||
|
||||
/** Anything not already a URL or a rooted path is a MinIO object key. */
|
||||
const isObjectKey = (src: string) => !/^(https?:\/\/|\/)/.test(src);
|
||||
|
||||
@Injectable()
|
||||
export class SupportContentService {
|
||||
constructor(
|
||||
private readonly repository: SupportContentRepository,
|
||||
private readonly minio: MinioService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Stores an image or video for the help page and returns its object key.
|
||||
*
|
||||
* The key is what gets saved in the document — never the signed URL. A
|
||||
* presigned URL expires, so persisting one would leave every embedded image
|
||||
* broken a few hours later; signing happens per read instead.
|
||||
*/
|
||||
async uploadMedia(
|
||||
file?: Express.Multer.File,
|
||||
): Promise<{ key: string; kind: PortalMediaKind; url: string }> {
|
||||
if (!file) throw new BadRequestException("No file uploaded");
|
||||
|
||||
const kind: PortalMediaKind | null = file.mimetype.startsWith("image/")
|
||||
? "image"
|
||||
: file.mimetype.startsWith("video/")
|
||||
? "video"
|
||||
: null;
|
||||
|
||||
if (!kind) {
|
||||
throw new BadRequestException(
|
||||
`Unsupported file type ${file.mimetype} — images and videos only`,
|
||||
);
|
||||
}
|
||||
|
||||
const key = `${SUPPORT_MEDIA_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`;
|
||||
await this.minio.uploadFile(key, file.buffer, file.mimetype);
|
||||
|
||||
return {
|
||||
key,
|
||||
kind,
|
||||
url: await this.minio.getSignedUrl(key, MEDIA_URL_TTL_SECONDS),
|
||||
};
|
||||
}
|
||||
|
||||
/** Presigned URL for one stored key, for the backoffice editor's previews. */
|
||||
async mediaUrl(key: string): Promise<{ url: string }> {
|
||||
if (!key?.startsWith(SUPPORT_MEDIA_PREFIX)) {
|
||||
throw new BadRequestException(
|
||||
`key must be an uploaded ${SUPPORT_MEDIA_PREFIX} object`,
|
||||
);
|
||||
}
|
||||
return { url: await this.minio.getSignedUrl(key, MEDIA_URL_TTL_SECONDS) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The public bundle. Missing rows fall back to the shipped defaults so an
|
||||
* unseeded or half-migrated environment still serves the legal pages rather
|
||||
* than 404-ing the first thing an anonymous visitor sees.
|
||||
*/
|
||||
async getBundle(): Promise<PortalContentBundle> {
|
||||
const documents = await this.repository.findAll();
|
||||
const bySlug = new Map(documents.map((d) => [d.slug, d.payload]));
|
||||
|
||||
const payload = <S extends SupportDocSlug>(slug: S) =>
|
||||
(bySlug.get(slug) ?? SUPPORT_CONTENT_DEFAULTS[slug]) as never;
|
||||
|
||||
return this.signMedia({
|
||||
contact: payload("CONTACT"),
|
||||
help: payload("HELP"),
|
||||
faq: payload("FAQ"),
|
||||
privacy: payload("PRIVACY"),
|
||||
terms: payload("TERMS"),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps every stored MinIO reference for a freshly signed URL: attachment
|
||||
* `src` keys, and `minio:<key>` references embedded in markdown by the
|
||||
* editor's image button.
|
||||
*
|
||||
* Each distinct key is signed once per request, and a signing failure
|
||||
* degrades to MinIO's public URL rather than failing the whole page (see
|
||||
* `MinioService.getSignedUrl`).
|
||||
*/
|
||||
private async signMedia(
|
||||
bundle: PortalContentBundle,
|
||||
): Promise<PortalContentBundle> {
|
||||
const keys = new Set<string>();
|
||||
|
||||
for (const section of bundle.help.sections ?? []) {
|
||||
for (const item of section.media ?? []) {
|
||||
if (isObjectKey(item.src)) keys.add(item.src);
|
||||
}
|
||||
}
|
||||
|
||||
const collect = (value: unknown): void => {
|
||||
if (typeof value === "string") {
|
||||
for (const match of value.matchAll(MEDIA_REF)) keys.add(match[1]);
|
||||
} else if (Array.isArray(value)) {
|
||||
value.forEach(collect);
|
||||
} else if (value && typeof value === "object") {
|
||||
Object.values(value).forEach(collect);
|
||||
}
|
||||
};
|
||||
collect(bundle);
|
||||
|
||||
if (keys.size === 0) return bundle;
|
||||
|
||||
const signed = new Map(
|
||||
await Promise.all(
|
||||
[...keys].map(
|
||||
async (key) =>
|
||||
[key, await this.minio.getSignedUrl(key, MEDIA_URL_TTL_SECONDS)] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const rewrite = (value: unknown): unknown => {
|
||||
if (typeof value === "string") {
|
||||
return value.replace(MEDIA_REF, (whole, key: string) =>
|
||||
signed.get(key) ?? whole,
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(rewrite);
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([k, v]) => [k, rewrite(v)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const resolved = rewrite(bundle) as PortalContentBundle;
|
||||
|
||||
for (const section of resolved.help.sections ?? []) {
|
||||
for (const item of section.media ?? []) {
|
||||
if (isObjectKey(item.src)) item.src = signed.get(item.src) ?? item.src;
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Admin list — metadata only, no payloads. */
|
||||
async list(): Promise<Omit<SupportDocument, "payload">[]> {
|
||||
const documents = await this.repository.findAll();
|
||||
return documents.map(({ payload: _payload, ...rest }) => rest);
|
||||
}
|
||||
|
||||
async getBySlug(rawSlug: string): Promise<SupportDocument> {
|
||||
const slug = assertSlug(rawSlug);
|
||||
const document = await this.repository.findBySlug(slug);
|
||||
if (!document) {
|
||||
throw new NotFoundException(`Support document ${slug} not found`);
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a document's whole payload. Writing the whole slice rather than
|
||||
* patching fields is deliberate: one editorial change becomes exactly one
|
||||
* version, which is what keeps the history readable.
|
||||
*/
|
||||
async update(
|
||||
rawSlug: string,
|
||||
dto: UpdateSupportDocumentDto,
|
||||
actorId: string | null,
|
||||
): Promise<SupportDocument> {
|
||||
const document = await this.getBySlug(rawSlug);
|
||||
const payload = validatePayload(document.slug, dto.payload);
|
||||
|
||||
document.payload = payload;
|
||||
document.version += 1;
|
||||
document.updatedById = actorId;
|
||||
|
||||
return this.repository.saveWithVersion(document, actorId, dto.note ?? null);
|
||||
}
|
||||
|
||||
async listVersions(rawSlug: string): Promise<SupportDocumentVersion[]> {
|
||||
const document = await this.getBySlug(rawSlug);
|
||||
return this.repository.findVersions(document.id);
|
||||
}
|
||||
|
||||
async getVersion(
|
||||
rawSlug: string,
|
||||
version: number,
|
||||
): Promise<SupportDocumentVersion> {
|
||||
const document = await this.getBySlug(rawSlug);
|
||||
const found = await this.repository.findVersion(document.id, version);
|
||||
if (!found) {
|
||||
throw new NotFoundException(
|
||||
`Version ${version} of ${document.slug} not found`,
|
||||
);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back to an earlier version by re-saving its payload through the normal
|
||||
* write path. The result is a NEW version whose content equals the old one —
|
||||
* never a destructive pointer reset — so the history only grows and a restore
|
||||
* is itself undoable.
|
||||
*/
|
||||
async restore(
|
||||
rawSlug: string,
|
||||
version: number,
|
||||
actorId: string | null,
|
||||
): Promise<SupportDocument> {
|
||||
const target = await this.getVersion(rawSlug, version);
|
||||
|
||||
return this.update(
|
||||
rawSlug,
|
||||
{
|
||||
payload: target.payload as unknown as Record<string, unknown>,
|
||||
note: `Restored version ${version}`,
|
||||
},
|
||||
actorId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------ helpers ------------------------------- */
|
||||
|
||||
export function assertSlug(raw: string): SupportDocSlug {
|
||||
const slug = raw?.toUpperCase() as SupportDocSlug;
|
||||
if (!SUPPORT_DOC_SLUGS.includes(slug)) {
|
||||
throw new BadRequestException(
|
||||
`Unknown support document "${raw}". Valid slugs: ${SUPPORT_DOC_SLUGS.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an incoming payload against the DTO registered for its slug, then
|
||||
* fill in any missing item ids. Exported so the seeder's spec can assert the
|
||||
* shipped defaults are themselves valid.
|
||||
*/
|
||||
export function validatePayload(
|
||||
slug: SupportDocSlug,
|
||||
raw: unknown,
|
||||
): SupportDocPayload {
|
||||
if (JSON.stringify(raw ?? null).length > MAX_PAYLOAD_BYTES) {
|
||||
throw new BadRequestException(
|
||||
`Payload for ${slug} exceeds ${MAX_PAYLOAD_BYTES} bytes`,
|
||||
);
|
||||
}
|
||||
|
||||
const instance = plainToInstance(PAYLOAD_DTO_BY_SLUG[slug], raw ?? {});
|
||||
const errors = validateSync(instance as object, {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
throw new BadRequestException(flattenErrors(errors));
|
||||
}
|
||||
|
||||
return withGeneratedIds(slug, instance as SupportDocPayload);
|
||||
}
|
||||
|
||||
function flattenErrors(errors: ValidationError[], path = ""): string[] {
|
||||
return errors.flatMap((error) => {
|
||||
const here = path ? `${path}.${error.property}` : error.property;
|
||||
const own = Object.values(error.constraints ?? {}).map(
|
||||
(message) => `${here}: ${message}`,
|
||||
);
|
||||
return [...own, ...flattenErrors(error.children ?? [], here)];
|
||||
});
|
||||
}
|
||||
|
||||
const withId = <T extends { id?: string }>(item: T): T => ({
|
||||
...item,
|
||||
id: item.id || randomUUID(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Item ids are the React keys in the portal, and admin-authored headings and
|
||||
* questions collide too easily to use as keys. Editors may omit them; the
|
||||
* server mints one rather than making every client remember to.
|
||||
*/
|
||||
function withGeneratedIds(
|
||||
slug: SupportDocSlug,
|
||||
payload: SupportDocPayload,
|
||||
): SupportDocPayload {
|
||||
switch (slug) {
|
||||
case "HELP": {
|
||||
const help = payload as PortalHelpContent;
|
||||
return {
|
||||
...help,
|
||||
sections: help.sections.map((section) => ({
|
||||
...withId(section),
|
||||
media: (section.media ?? []).map(withId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
case "FAQ": {
|
||||
const faq = payload as PortalFaqContent;
|
||||
return {
|
||||
...faq,
|
||||
groups: faq.groups.map((group) => ({
|
||||
...withId(group),
|
||||
items: group.items.map(withId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
case "PRIVACY":
|
||||
case "TERMS": {
|
||||
const legal = payload as PortalLegalContent;
|
||||
return { ...legal, sections: legal.sections.map(withId) };
|
||||
}
|
||||
default:
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
||||
import {
|
||||
DataSource,
|
||||
EntityManager,
|
||||
@@ -2522,63 +2521,6 @@ export class TrainSchedulingService {
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* EXPORT ONLY. An export train must not leave carrying nothing while its cargo
|
||||
* sits in the shed: the goods are received into the origin warehouse, GRN'd and
|
||||
* loaded onto the wagons allocated to the booking, so anything still in the
|
||||
* warehouse at dispatch is being left behind. Blocks dispatch when an allocated
|
||||
* booking has warehouse inventory that never made it onto a wagon (received /
|
||||
* stored / ready but not LOADED) — either load it from the Load-to-Train queue,
|
||||
* or drop the booking's wagon allocation so it rides a later train.
|
||||
*
|
||||
* Import/domestic are untouched: their cargo isn't loaded out of an origin
|
||||
* warehouse, so warehouse inventory says nothing about what's aboard.
|
||||
*
|
||||
* Bookings with no warehouse inventory at all are NOT blocked — allocating a
|
||||
* wagon before the goods arrive is normal planning; they simply aren't aboard.
|
||||
*/
|
||||
private async assertAllocatedCargoLoaded(scheduleId: string): Promise<void> {
|
||||
const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT oy.country AS "originCountry", dy.country AS "destinationCountry"
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
WHERE ts.id = $1 AND ts.deleted_at IS NULL`,
|
||||
[scheduleId],
|
||||
);
|
||||
if (!route) return;
|
||||
const direction = deriveTradeDirection(
|
||||
{ country: route.originCountry },
|
||||
{ country: route.destinationCountry },
|
||||
);
|
||||
if (direction !== 'EXPORT') return;
|
||||
|
||||
// Only bookings boarding at the schedule's ORIGIN station gate dispatch —
|
||||
// a mid-corridor boarder (origin B on an A→B→C→D run) is loaded when the
|
||||
// train reaches its yard, so its warehouse state says nothing at departure.
|
||||
const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query(
|
||||
`WITH ${SCHEDULE_BOOKINGS_CTE}
|
||||
SELECT DISTINCT b.reference AS "reference", inv.status AS "status"
|
||||
FROM sched_bookings sb
|
||||
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
|
||||
JOIN freight.train_schedules ts ON ts.id = sb.schedule_id
|
||||
JOIN freight.warehouse_inventory inv
|
||||
ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
WHERE sb.schedule_id = $1
|
||||
AND b.origin_yard_id = ts.origin_station_id
|
||||
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`,
|
||||
[scheduleId],
|
||||
);
|
||||
if (rows.length) {
|
||||
const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', ');
|
||||
throw new BadRequestException(
|
||||
`Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` +
|
||||
`Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchSchedule(scheduleId: string) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
@@ -2588,8 +2530,8 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||||
}
|
||||
await this.assertImportDjiboutiMayDepart(schedule);
|
||||
// Export only: don't leave received cargo behind in the warehouse.
|
||||
await this.assertAllocatedCargoLoaded(scheduleId);
|
||||
// Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon)
|
||||
// never blocks departure — the dispatch confirm dialog warns and staff decide.
|
||||
// A locomotive may sit on many future schedules, but it can only pull one train
|
||||
// at a time — block dispatch while any set locomotive is out on a dispatched train.
|
||||
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
|
||||
/**
|
||||
* Export self-haul has no separate "truck arrived" gate action the way import
|
||||
* does (release()'s arrival branch, fired later when a truck shows up to
|
||||
* COLLECT already-warehoused goods) — the truck delivering cargo TO the
|
||||
* warehouse arrives and is received in the same act, so receive()/
|
||||
* bulkReceive() must stamp customer_truck_assignments.arrived_at themselves.
|
||||
*/
|
||||
type Marker = (
|
||||
manager: { query: jest.Mock },
|
||||
bookingId: string,
|
||||
plateNumber: string | null | undefined,
|
||||
) => Promise<void>;
|
||||
|
||||
function makeMarker() {
|
||||
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
|
||||
const marker = (
|
||||
service as unknown as { markCustomerTruckArrived: Marker }
|
||||
).markCustomerTruckArrived.bind(service);
|
||||
return marker;
|
||||
}
|
||||
|
||||
describe('markCustomerTruckArrived', () => {
|
||||
it('stamps arrival matched by booking + plate', async () => {
|
||||
const marker = makeMarker();
|
||||
const manager = { query: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await marker(manager, 'b-1', 'AAA-2323');
|
||||
|
||||
expect(manager.query).toHaveBeenCalledTimes(1);
|
||||
const [sql, params] = manager.query.mock.calls[0];
|
||||
expect(sql).toMatch(/UPDATE freight\.customer_truck_assignments/);
|
||||
expect(sql).toMatch(/UPPER\(a\.plate_number\) = UPPER\(\$2\)/);
|
||||
expect(params).toEqual(['b-1', 'AAA-2323']);
|
||||
});
|
||||
|
||||
it('trims the plate before matching', async () => {
|
||||
const marker = makeMarker();
|
||||
const manager = { query: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await marker(manager, 'b-1', ' AAA-2323 ');
|
||||
|
||||
expect(manager.query.mock.calls[0][1]).toEqual(['b-1', 'AAA-2323']);
|
||||
});
|
||||
|
||||
it('no-ops on a missing/blank plate — no query, nothing to match on', async () => {
|
||||
const marker = makeMarker();
|
||||
const manager = { query: jest.fn() };
|
||||
|
||||
await marker(manager, 'b-1', undefined);
|
||||
await marker(manager, 'b-1', ' ');
|
||||
|
||||
expect(manager.query).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1643,6 +1643,12 @@ export class WarehouseInventoryService {
|
||||
[bookingId],
|
||||
);
|
||||
|
||||
// Export self-haul: this receive IS the truck's arrival — see
|
||||
// markCustomerTruckArrived / receive()'s single-booking mirror.
|
||||
if (dto.direction === 'EXPORT') {
|
||||
await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber);
|
||||
}
|
||||
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
@@ -1691,7 +1697,6 @@ export class WarehouseInventoryService {
|
||||
/** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */
|
||||
private async exportInventoryByStatus(
|
||||
status: WarehouseInventoryStatus,
|
||||
requireInspectionPassed = false,
|
||||
): Promise<ReadyToLoadRow[]> {
|
||||
const rows: Array<
|
||||
ReadyToLoadRow & { originCountry: string | null; destinationCountry: string | null }
|
||||
@@ -1720,7 +1725,6 @@ export class WarehouseInventoryService {
|
||||
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||||
WHERE inv.deleted_at IS NULL
|
||||
AND inv.status = $1
|
||||
${requireInspectionPassed ? `AND inv.inspection_status = 'PASSED'` : ''}
|
||||
ORDER BY inv.created_at DESC`,
|
||||
[status],
|
||||
);
|
||||
@@ -1733,9 +1737,13 @@ export class WarehouseInventoryService {
|
||||
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest);
|
||||
}
|
||||
|
||||
/** EXPORT inventory that passed inspection and is waiting to be loaded (READY_FOR_LOADING). */
|
||||
/**
|
||||
* EXPORT inventory waiting to be loaded (READY_FOR_LOADING). Inspection state
|
||||
* rides along on each row for the UI to show, but does not filter the queue —
|
||||
* uninspected cargo must still be loadable.
|
||||
*/
|
||||
async readyToLoadExport(): Promise<ReadyToLoadRow[]> {
|
||||
return this.exportInventoryByStatus('READY_FOR_LOADING', true);
|
||||
return this.exportInventoryByStatus('READY_FOR_LOADING');
|
||||
}
|
||||
|
||||
/** EXPORT inventory received at the facility and awaiting inspection. */
|
||||
@@ -2886,6 +2894,13 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
}
|
||||
|
||||
// Export self-haul: this receive IS the truck's arrival — stamp it on
|
||||
// its own customer_truck_assignments row (mirror of import's arrival,
|
||||
// see markCustomerTruckArrived).
|
||||
if (dto.bookingId && bookingDirection === 'EXPORT') {
|
||||
await this.markCustomerTruckArrived(manager, dto.bookingId, truckEntrance.truckPlateNumber);
|
||||
}
|
||||
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
@@ -3117,9 +3132,8 @@ export class WarehouseInventoryService {
|
||||
if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) {
|
||||
throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep');
|
||||
}
|
||||
if (item.inspectionStatus !== 'PASSED') {
|
||||
throw new BadRequestException('Inventory must pass inspection before it can be marked ready for loading');
|
||||
}
|
||||
// Inspection is tracked, not enforced — uninspected cargo may still be
|
||||
// marked ready and loaded so a train is never held for paperwork.
|
||||
return this.transition(id, 'READY_FOR_LOADING', {
|
||||
timestampField: 'readyForLoadingAt',
|
||||
activityType: 'READY_FOR_LOADING',
|
||||
@@ -6008,6 +6022,33 @@ export class WarehouseInventoryService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EXPORT self-haul mirror of the customer truck lifecycle IMPORT already has:
|
||||
* import stamps a truck's arrival on the SEPARATE gate action that comes
|
||||
* later (release()'s arrival branch, when the customer's truck shows up to
|
||||
* COLLECT already-warehoused goods). Export has no such separate step — the
|
||||
* truck delivering cargo TO the warehouse arrives and is received in the
|
||||
* same act, so receive()/bulkReceive() themselves are the arrival event.
|
||||
* Matched by plate (not assignmentId — neither receive endpoint carries
|
||||
* one), same as release()'s departure-branch self-haul UPDATE.
|
||||
*/
|
||||
private async markCustomerTruckArrived(
|
||||
manager: EntityManager,
|
||||
bookingId: string,
|
||||
plateNumber: string | null | undefined,
|
||||
): Promise<void> {
|
||||
const plate = plateNumber?.trim();
|
||||
if (!plate) return;
|
||||
await manager.query(
|
||||
`UPDATE freight.customer_truck_assignments a
|
||||
SET arrived_at = COALESCE(a.arrived_at, NOW()), updated_at = NOW()
|
||||
WHERE a.booking_id = $1
|
||||
AND UPPER(a.plate_number) = UPPER($2)
|
||||
AND a.deleted_at IS NULL`,
|
||||
[bookingId, plate],
|
||||
);
|
||||
}
|
||||
|
||||
private async getBookingTruckEntranceSource(
|
||||
manager: EntityManager,
|
||||
bookingId: string,
|
||||
@@ -6027,6 +6068,10 @@ export class WarehouseInventoryService {
|
||||
firstMileDriverPhone?: string | null;
|
||||
firstMileDriverLicenseNumber?: string | null;
|
||||
firstMileTruckType?: string | null;
|
||||
customerTruckPlateNumber?: string | null;
|
||||
customerTruckDriverName?: string | null;
|
||||
customerTruckType?: string | null;
|
||||
customerTruckContainerNumber?: string | null;
|
||||
}> {
|
||||
const [booking] = await manager.query(
|
||||
`SELECT b.reference AS "reference",
|
||||
@@ -6046,7 +6091,24 @@ export class WarehouseInventoryService {
|
||||
) AS "firstMileDriverName",
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType"
|
||||
v.vehicle_type AS "firstMileTruckType",
|
||||
-- Self-haul truck assigned via the portal — export delivering to
|
||||
-- the warehouse or import collecting from it. Same pattern as
|
||||
-- eligibleBookings/importQueueByStatuses: multi-truck self-haul
|
||||
-- writes plates/drivers to customer_truck_assignments and leaves
|
||||
-- the booking columns null, so read the assignments first and
|
||||
-- keep the legacy column as the fallback for single-truck
|
||||
-- bookings written before that table existed.
|
||||
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
|
||||
FROM freight.customer_truck_assignments cta
|
||||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
|
||||
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
|
||||
FROM freight.customer_truck_assignments cta
|
||||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||
b.customer_truck_driver_name) AS "customerTruckDriverName",
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
${primaryContactUserJoin('company')}
|
||||
|
||||
Reference in New Issue
Block a user