From bd9f7f354a412888a35c08d1a8896128ad6c656f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 12 Aug 2026 09:31:31 +0000 Subject: [PATCH 1/4] feat(freight-api): enrich canonical request log line Emit the request line as raw JSON on stdout (level/time/logger as fields) instead of through Nest's console logger, whose prefix made it unparsable. Collect data points via logCtx at the flow chokepoints: BaseRepository writes (status changes, creates, deletes), invoice transitions, payment intent lifecycle + outbound payment-service calls, booking/contract entry state, review-note reasons, signatures and OTP verify outcomes. --- .../src/common/request-log-context.spec.ts | 50 ++++++++++++- .../src/modules/billing/billing.service.ts | 59 ++++++++++++++- .../modules/bookings/bookings.repository.ts | 9 ++- .../src/modules/bookings/bookings.service.ts | 17 ++++- .../contracts/contract-transition.service.ts | 15 +++- .../modules/contracts/contracts.service.ts | 14 +++- .../src/modules/otp/otp.service.ts | 8 ++ .../modules/payment/payment-client.service.ts | 14 ++++ .../src/modules/payment/payment.service.ts | 73 +++++++++++++++++++ .../src/logging/request-log.middleware.ts | 18 ++++- .../src/repositories/base.repository.ts | 32 +++++++- 11 files changed, 295 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-api/src/common/request-log-context.spec.ts b/apps/edr-freight-api/src/common/request-log-context.spec.ts index 0aefe3695..403b1a137 100644 --- a/apps/edr-freight-api/src/common/request-log-context.spec.ts +++ b/apps/edr-freight-api/src/common/request-log-context.spec.ts @@ -1,5 +1,7 @@ import { Logger } from "@nestjs/common"; +import type { Repository } from "typeorm"; import { + BaseRepository, RequestLogMiddleware, getLogContext, logCtx, @@ -47,10 +49,13 @@ describe("logCtx", () => { describe("RequestLogMiddleware", () => { it("emits one canonical JSON line carrying the collected context", () => { + // Raw stdout, not the Nest logger — the line must be parsable JSON with no + // "[Nest] … LOG [request]" prefix in front of it. const lines: string[] = []; - jest - .spyOn(Logger.prototype, "warn") - .mockImplementation((m) => lines.push(String(m))); + jest.spyOn(process.stdout, "write").mockImplementation((chunk) => { + lines.push(String(chunk)); + return true; + }); jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined); const listeners: Record void> = {}; @@ -82,7 +87,11 @@ describe("RequestLogMiddleware", () => { listeners.close(); // aborts/close after finish must not double-log expect(lines).toHaveLength(1); + expect(lines[0].endsWith("\n")).toBe(true); + expect(lines[0].startsWith("{")).toBe(true); expect(JSON.parse(lines[0])).toMatchObject({ + level: "warn", + logger: "request", type: "http_request", requestId: "req-42", method: "POST", @@ -100,3 +109,38 @@ describe("RequestLogMiddleware", () => { jest.restoreAllMocks(); }); }); + +describe("BaseRepository write trail", () => { + class TestRepo extends BaseRepository<{ id: string; status?: string }> { + constructor(repo: Repository<{ id: string; status?: string }>) { + super(repo); + } + } + + const typeormRepo = { + metadata: { tableName: "booking" }, + create: (data: unknown) => data, + save: async (data: unknown) => data, + update: async () => undefined, + findOne: async () => ({ id: "b-1", status: "SUBMITTED" }), + softDelete: async () => undefined, + delete: async () => undefined, + } as unknown as Repository<{ id: string; status?: string }>; + + it("records creates, status changes and deletes without any service opting in", async () => { + const ctx = await runWithLogContext({}, async () => { + const repo = new TestRepo(typeormRepo); + await repo.create({ id: "b-1" }); + await repo.update("b-1", { status: "SUBMITTED" }); + await repo.update("b-1", { id: "b-1" }); // no status → no transition entry + await repo.softDelete("b-1"); + return getLogContext(); + }); + + expect(ctx).toEqual({ + db: { created: { booking: 1 }, updated: { booking: 2 } }, + statusChanges: [{ entity: "booking", id: "b-1", status: "SUBMITTED" }], + deleted: [{ entity: "booking", id: "b-1" }], + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 9a31466f0..6c7e8455d 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -8,6 +8,7 @@ import { NotFoundException, } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; +import { logCtx } from "@edr/api-common"; import { DataSource, EntityManager, In } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; @@ -951,6 +952,24 @@ export class BillingService { await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); + // Every invoice status move in the app funnels through here — money + // changing state is the single most-asked question in support. + logCtx( + { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + source: invoice.source, + sourceId: invoice.sourceId, + from: invoice.status, + to: status, + event, + amount: Number(invoice.totalAmount), + currency: invoice.currency, + paymentId: extra.paymentId ?? invoice.paymentId ?? undefined, + }, + { path: "invoiceTransitions", mode: "push" }, + ); + const updated = { ...invoice, ...extra, status } as Invoice; return { result: updated, @@ -1320,6 +1339,21 @@ export class BillingService { throw new BadRequestException("Invoice has no outstanding balance."); } + logCtx( + { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + source: invoice.source, + sourceId: invoice.sourceId, + companyId: invoice.companyId, + amountDue, + currency: invoice.currency, + method: opts.method ?? "TELEBIRR", + platform: opts.platform, + }, + { path: "payment.payInvoice" }, + ); + // CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is // required up front (the payment service rejects it otherwise, as a 502 here). if ( @@ -1443,7 +1477,24 @@ export class BillingService { // first on DESC, which would hand back an unissued invoice. order: { issuedAt: { direction: "DESC", nulls: "LAST" } }, }); - if (!invoice) return null; + if (!invoice) { + logCtx( + { paymentId, outcome: "no-invoice-for-payment" }, + { path: "payment.settleInvoice" }, + ); + return null; + } + + logCtx( + { + paymentId, + providerTxnId, + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + invoiceStatus: invoice.status, + }, + { path: "payment.settleInvoice" }, + ); const settleable: Freight.InvoiceStatus[] = [ ...OPEN_STATUSES, @@ -1453,6 +1504,12 @@ export class BillingService { // Already PAID is the ordinary idempotent no-op (redelivery, or settled // inline by payInvoice). Anything else means money was captured with // nowhere to land — that needs a person, so say so loudly. + logCtx( + invoice.status === Freight.InvoiceStatus.Paid + ? "already-paid" + : "captured-with-nowhere-to-land", + { path: "payment.settleInvoice.outcome", mode: "set" }, + ); if (invoice.status !== Freight.InvoiceStatus.Paid) { this.logger.error( `Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` + diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 1c5cc7481..2b1463a95 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1,4 +1,4 @@ -import { BaseRepository } from '@edr/api-common'; +import { BaseRepository, logCtx } from '@edr/api-common'; import { SchedulingStatus } from '@edr/types'; import { ConflictException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; @@ -631,6 +631,13 @@ export class BookingsRepository extends BaseRepository { authorId?: string, ): Promise { const repo = this.dataSource.getRepository(BookingReviewNote); + // Every rejection/cancellation/change-request reason in the booking flow is + // written through here — the "why" behind the status change on the same log + // line as the status change itself. + logCtx( + { bookingId, type, note, authorId }, + { path: 'reviewNotes', mode: 'push' }, + ); return repo.save( repo.create({ bookingId, note, type, authorId: authorId ?? null }), ); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 121c889bc..6c3f14ecb 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -9,7 +9,7 @@ import { NotFoundException, } from '@nestjs/common'; import { Freight, SchedulingStatus } from '@edr/types'; -import { insertWithGeneratedReference } from '@edr/api-common'; +import { insertWithGeneratedReference, logCtx } from '@edr/api-common'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; @@ -2115,6 +2115,21 @@ export class BookingsService { throw new NotFoundException(`Booking ${id} not found`); } + // Nearly every booking flow loads the booking through here, so this one + // call puts the human-searchable reference + entry state on the request log + // line for all of them. Entry state only — the write trail (statusChanges) + // shows where it ended up. + logCtx( + { + id: booking.id, + reference: booking.reference, + statusAtEntry: booking.status, + companyId: booking.companyId, + contractId: booking.contractId ?? undefined, + }, + { path: "booking" }, + ); + if (booking.files && booking.files.length > 0) { booking.files = await Promise.all( booking.files.map(async (file: FileRecord) => { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index fc3a238ea..e6f208ae2 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -9,7 +9,7 @@ import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { randomUUID } from 'node:crypto'; import { Readable } from 'stream'; -import { insertWithGeneratedReference } from '@edr/api-common'; +import { insertWithGeneratedReference, logCtx } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder'; @@ -1084,6 +1084,19 @@ export class ContractTransitionService { ): Promise { const role = dto.role as ContractSignerRole; + // Both sign() and counterSign() land here — who signed what, and whether the + // ink came from the request or the signer's saved profile signature. + logCtx( + { + contractId: contract.id, + reference: contract.reference, + role, + signerUserId: options.signerUserId, + usedDrawnImage: Boolean(dto.signatureImageBase64), + }, + { path: "contractSignatures", mode: "push" }, + ); + // Resolve the signature image. The client may send a freshly-drawn image, or // omit it to reuse the signer's saved profile signature. Fall back to the // saved one whenever no image is supplied. diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 6e170f126..607a6f525 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -7,7 +7,7 @@ import { } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; -import { insertWithGeneratedReference } from '@edr/api-common'; +import { insertWithGeneratedReference, logCtx } from '@edr/api-common'; import { YardCountry } from '@edr/types'; // @@ -811,6 +811,18 @@ export class ContractsService { throw new NotFoundException(`Contract ${id} not found`); } + // Entry state for every contract flow (submit, approve, sign, suspend…) — + // see the equivalent in BookingsService.findById. + logCtx( + { + id: contract.id, + reference: contract.reference, + statusAtEntry: contract.status, + companyId: contract.companyId, + }, + { path: "contract" }, + ); + if (contract.files && contract.files.length > 0) { contract.files = await Promise.all( contract.files.map(async (file: FileRecord) => { diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index a7361fbdd..42d46226a 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -1,6 +1,7 @@ // otp.service.ts import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { logCtx } from "@edr/api-common"; import { randomInt } from "node:crypto"; import { OtpRepository } from "./otp.repository"; @@ -317,6 +318,13 @@ export class OtpService { }`; if (result === "ok") this.logger.log(line); else this.logger.warn(line); + + // Outcome only — the target is a phone number / email address and stays out + // of the canonical line. Channels are safe and say which one was used. + logCtx( + { mode, result, channels: channelsOf(target) }, + { path: "otp.verify", mode: "push" }, + ); } /** diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts index 26a0cf5ed..de220fd59 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -7,6 +7,7 @@ import { import { HttpService } from "@nestjs/axios"; import { AxiosError } from "axios"; import { firstValueFrom } from "rxjs"; +import { logCtx } from "@edr/api-common"; import { InitiatePaymentRequest, PaymentIntentSnapshot, @@ -111,6 +112,16 @@ export class PaymentClientService { body?: unknown, ): Promise { const url = `${this.baseUrl}${path}`; + // Every hop to the payment service lands on the request log line: which + // call, how slow, and what it answered. A settle that never happened is + // almost always one of these coming back 4xx/5xx or timing out. + const startedAt = Date.now(); + const trace = (extra: Record) => + logCtx( + { method, path, ms: Date.now() - startedAt, ...extra }, + { path: "outbound.payment", mode: "push" }, + ); + try { const response = await firstValueFrom( this.http.request({ @@ -122,9 +133,11 @@ export class PaymentClientService { : {}, }), ); + trace({ status: response.status }); return response.data; } catch (err) { if (err instanceof AxiosError && err.response) { + trace({ status: err.response.status }); if (err.response.status === 404) throw err; const detail = (err.response.data as { message?: string | string[] })?.message ?? @@ -136,6 +149,7 @@ export class PaymentClientService { } const message = err instanceof Error && err.message ? err.message : String(err); + trace({ unreachable: true, error: message }); this.logger.error( `payment service unreachable (${method} ${path}): ${message}`, ); diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 316f3e8fe..4d27c8c77 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -7,6 +7,7 @@ import { Logger, NotFoundException, } from "@nestjs/common"; +import { logCtx } from "@edr/api-common"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { PaymentEntity } from "./entities/payment.entity"; import { PaymentRepository } from "./payment.repository"; @@ -214,8 +215,16 @@ export class PaymentService { PaymentReferenceType.SHIPMENT, referenceId, ); + logCtx( + { referenceId, paid: result.paid, unverifiable: result.unverifiable }, + { path: "payment.reconcile" }, + ); return { paid: result.paid, unverifiable: result.unverifiable }; } catch (err) { + logCtx( + { referenceId, unverifiable: true, error: (err as Error).message }, + { path: "payment.reconcile" }, + ); this.logger.warn( `Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`, ); @@ -224,6 +233,18 @@ export class PaymentService { } async initiate(input: InitiateIntentInput): Promise { + logCtx( + { + referenceId: input.referenceId, + source: input.source, + orderRef: input.orderRef, + method: input.method, + amountMinor: input.amountMinor, + currency: input.currency, + platform: input.platform, + }, + { path: "payment.initiate" }, + ); try { const isCbeBill = input.method === ProviderMethod.CBE_BILL; // CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8). @@ -268,6 +289,17 @@ export class PaymentService { const intent = await this.upsertIntent(input, snapshot); + logCtx( + { + intentId: intent.id, + providerStatus: snapshot.status, + providerTxnId: snapshot.providerTxnId, + merchantOrderId: snapshot.merchantOrderId, + immediateSuccess, + }, + { path: "payment.initiate" }, + ); + if (immediateSuccess) { // Settle the projection but DO NOT notify billing — billing settles // inline once it has stored intentId on the invoice (see payInvoice), @@ -428,6 +460,17 @@ export class PaymentService { otp, ); + logCtx( + { + intentId: local.id, + refId: local.refId, + gatewayIntentId: snapshot.intentId, + providerStatus: confirmed.status, + failureCode: confirmed.failureCode, + }, + { path: "payment.confirmOtp" }, + ); + if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) { await this.markIntentSucceeded(local.id, { providerTxnId: confirmed.providerTxnId, @@ -460,6 +503,16 @@ export class PaymentService { ): Promise<{ alreadyFinalized: boolean }> { const intent = await this.paymentRepo.findOneBy({ id: intentId }); if (!intent) throw new NotFoundException("PaymentIntent not found"); + logCtx( + { + intentId, + refId: intent.refId, + priorStatus: intent.status, + providerTxnId: opts.providerTxnId, + notifyBilling: opts.notify !== false, + }, + { path: "payment.settle" }, + ); if (intent.status === "success") { // Still notify billing: a prior delivery may have flipped the intent to // success and then died before the invoice settled (the two steps are not @@ -471,6 +524,7 @@ export class PaymentService { opts.paidAt ?? intent.paidAt ?? undefined, ); } + logCtx(true, { path: "payment.settle.alreadyFinalized", mode: "set" }); return { alreadyFinalized: true }; } @@ -507,6 +561,15 @@ export class PaymentService { referenceId: string, ): Promise<{ acknowledged: boolean }> { const intent = await this.paymentRepo.findOneBy({ refId: referenceId }); + logCtx( + { + referenceId, + intentId: intent?.id, + intentStatus: intent?.status ?? "none", + method: intent?.method, + }, + { path: "payment.successRedirect" }, + ); if (!intent || intent.method === "cbe-bill") { return { acknowledged: false }; } @@ -533,6 +596,16 @@ export class PaymentService { }): Promise { const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); if (!intent) throw new NotFoundException("PaymentIntent not found"); + logCtx( + { + intentId: intent.id, + refId: intent.refId, + priorStatus: intent.status, + failureCode: input.failureCode, + failureMessage: input.failureMessage, + }, + { path: "payment.failed" }, + ); if (intent.status === "success" || intent.status === "canceled") return; await this.paymentRepo.update( diff --git a/packages/api-common/src/logging/request-log.middleware.ts b/packages/api-common/src/logging/request-log.middleware.ts index a8067bf02..b25463161 100644 --- a/packages/api-common/src/logging/request-log.middleware.ts +++ b/packages/api-common/src/logging/request-log.middleware.ts @@ -59,7 +59,6 @@ const userId = (req: LoggedRequest): string | undefined => { @Injectable() export class RequestLogMiddleware implements NestMiddleware { private readonly logger = new Logger("HTTP"); - private readonly canonical = new Logger("request"); use(req: LoggedRequest, res: LoggedResponse, next: () => void): void { const start = Date.now(); @@ -90,6 +89,12 @@ export class RequestLogMiddleware implements NestMiddleware { const line = { ...ctx, + // Fields the Nest console prefix used to carry. They are IN the JSON + // now because this line is written raw (see below) — a log shipper + // needs level and time as parsable fields, not as console decoration. + time: new Date().toISOString(), + level: status >= 500 ? "error" : status >= 400 ? "warn" : "info", + logger: "request", type: "http_request", requestId, method: req.method, @@ -113,6 +118,9 @@ export class RequestLogMiddleware implements NestMiddleware { json = JSON.stringify(line); } catch { json = JSON.stringify({ + time: line.time, + level: line.level, + logger: "request", type: "http_request", requestId, method: req.method, @@ -123,9 +131,11 @@ export class RequestLogMiddleware implements NestMiddleware { }); } - if (status >= 500) this.canonical.error(json); - else if (status >= 400) this.canonical.warn(json); - else this.canonical.log(json); + // Written raw, NOT through Nest's Logger: the console logger wraps every + // message in "[Nest] pid - date LEVEL [ctx] …", which makes the line + // un-parsable as JSON. Same destination the Nest logger writes to + // (stdout, stderr for errors) — only the decoration is dropped. + (status >= 500 ? process.stderr : process.stdout).write(`${json}\n`); }; res.on("finish", emit); diff --git a/packages/api-common/src/repositories/base.repository.ts b/packages/api-common/src/repositories/base.repository.ts index 6374d84a7..c0cc7073b 100644 --- a/packages/api-common/src/repositories/base.repository.ts +++ b/packages/api-common/src/repositories/base.repository.ts @@ -7,9 +7,16 @@ import { Repository, } from "typeorm"; +import { logCtx } from "../logging/request-context"; + export abstract class BaseRepository { protected constructor(protected readonly repository: Repository) {} + /** Table name, for the write trail on the canonical request log line. */ + private get table(): string { + return this.repository.metadata.tableName; + } + /** Find a single entity by its primary key. */ async findById( id: string, @@ -34,22 +41,43 @@ export abstract class BaseRepository { /** Create and persist a new entity. */ async create(data: DeepPartial): Promise { const entity = this.repository.create(data); - return this.repository.save(entity); + const saved = await this.repository.save(entity); + logCtx(1, { path: `db.created.${this.table}`, mode: "count" }); + return saved; } - /** Patch an entity in place and return the reloaded row. */ + /** + * Patch an entity in place and return the reloaded row. + * + * Every domain status machine in this app (booking, contract, wagon, + * warehouse, transfer request…) lands here, so this is the one place that can + * record "what state did this request actually move" without every service + * remembering to log it. + */ async update(id: string, data: DeepPartial): Promise { await this.repository.update(id, data as never); + logCtx(1, { path: `db.updated.${this.table}`, mode: "count" }); + if (data && typeof data === "object" && "status" in data) { + logCtx( + { entity: this.table, id, status: (data as { status: unknown }).status }, + { path: "statusChanges", mode: "push" }, + ); + } return this.findById(id); } /** Soft-delete an entity by primary key (sets deleted_at). */ async softDelete(id: string): Promise { await this.repository.softDelete(id); + logCtx({ entity: this.table, id }, { path: "deleted", mode: "push" }); } /** Permanently delete an entity. Avoid in domain code; prefer softDelete. */ async hardDelete(id: string): Promise { await this.repository.delete(id); + logCtx( + { entity: this.table, id, hard: true }, + { path: "deleted", mode: "push" }, + ); } } From 068ee49a9f39b682bd669d702cf1a5b18b040d42 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 12 Aug 2026 09:33:21 +0000 Subject: [PATCH 2/4] style: invoice --- .../src/components/customers/PersonCard.tsx | 112 +++ .../src/components/customers/index.ts | 5 + .../pages/customers/CustomerDetailPage.tsx | 830 +++++++++--------- 3 files changed, 528 insertions(+), 419 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/customers/PersonCard.tsx diff --git a/apps/edr-freight-web/backoffice/src/components/customers/PersonCard.tsx b/apps/edr-freight-web/backoffice/src/components/customers/PersonCard.tsx new file mode 100644 index 000000000..b4a201fd1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/PersonCard.tsx @@ -0,0 +1,112 @@ +import { Badge, Card, Divider, Group, Stack, Text } from "@mantine/core"; +import type { ReactNode } from "react"; + +export interface PersonField { + label: string; + value?: string | null; +} + +export interface PersonCardProps { + /** OWNER / POA / CONTACT PERSON — the person's role, not their name. */ + title: string; + icon?: ReactNode; + /** + * Identity state. `undefined` = this person has no identity check at all + * (contact person), so no badge is rendered rather than a misleading "not + * verified" one. + */ + verified?: boolean; + /** Extra pills after the verification badge (e.g. "Verifies for this company"). */ + badges?: ReactNode; + /** Rendered between the header and the fields — alerts, match warnings. */ + notice?: ReactNode; + fields: PersonField[]; + /** Shown when the API returned nothing for every field. */ + emptyMessage: string; + /** Attachments or anything else that belongs to this person. */ + children?: ReactNode; +} + +/** + * One person in the customer's people column: owner, power of attorney, contact + * person. Empty fields are dropped rather than rendered as "—", so a field the + * API stops sending simply disappears instead of leaving a dead row behind. + */ +export function PersonCard({ + title, + icon, + verified, + badges, + notice, + fields, + emptyMessage, + children, +}: PersonCardProps) { + const filled = fields.filter( + (f) => f.value != null && String(f.value).trim(), + ); + + return ( + + + + {icon} + + {title} + + {verified !== undefined && + (verified ? ( + + Fayda verified + + ) : ( + + Not verified + + ))} + {badges} + + + {notice} + + {filled.length > 0 ? ( + + {filled.map((f) => ( + + + {f.label} + + + {f.value} + + + ))} + + ) : ( + + {emptyMessage} + + )} + + {children && ( + <> + + {children} + + )} + + + ); +} + +export default PersonCard; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 81f25fcb2..daeb11311 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -24,4 +24,9 @@ export { type ResetPasswordActionProps, } from "./ResetPasswordAction"; export { formatBytes, formatDate, formatMoney, humanize } from "./format"; +export { + PersonCard, + type PersonCardProps, + type PersonField, +} from "./PersonCard"; export { TableCard, type TableCardProps } from "./TableCard"; diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index a6367786a..73f6a618b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -8,7 +8,7 @@ import { Card, Center, Container, - Divider, + Grid, Group, Loader, SimpleGrid, @@ -21,6 +21,7 @@ import { ArrowLeft, ArrowRight, Banknote, + Contact, Download, Eye, FileText, @@ -32,6 +33,8 @@ import { FilePen, Paperclip, Receipt, + UserCheck, + UserRound, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; @@ -46,6 +49,7 @@ import { CompanyTypeBadge, InvoiceStatusBadge, PaymentStatusBadge, + PersonCard, ProfileApprovalActions, ProfileChips, ProfileStatusBadge, @@ -259,7 +263,9 @@ export default function CustomerDetailPage() { variant="subtle" color="gray" aria-label={`View ${f.name}`} - onClick={() => void fetchViewableFile(f.id, f.name).then(view)} + onClick={() => + void fetchViewableFile(f.id, f.name).then(view) + } > @@ -268,7 +274,9 @@ export default function CustomerDetailPage() { type="button" size="xs" lineClamp={1} - onClick={() => void fetchViewableFile(f.id, f.name).then(view)} + onClick={() => + void fetchViewableFile(f.id, f.name).then(view) + } style={{ maxWidth: 170, textAlign: "left", @@ -615,7 +623,10 @@ export default function CustomerDetailPage() { [], ); - const licenseProfiles = (company?.companyProfiles ?? []).filter( + /** Never read `company.companyProfiles` directly — an endpoint that stops + * loading the relation would otherwise crash the whole page. */ + const profiles = company?.companyProfiles ?? []; + const licenseProfiles = profiles.filter( (p) => p.licenseFiles && p.licenseFiles.length > 0, ); @@ -629,14 +640,13 @@ export default function CustomerDetailPage() { [documents], ); const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE); - const poaFields = [ - { label: "PoA name", value: company?.poaName }, - { label: "PoA email", value: company?.poaEmail }, - { label: "PoA phone", value: company?.poaPhone }, - { label: "PoA location", value: company?.poaLocation }, - { label: "PoA address", value: company?.poaAddress }, - ]; - const hasPoaDetails = poaFields.some((f) => f.value?.trim()); + const hasPoaDetails = [ + company?.poaName, + company?.poaEmail, + company?.poaPhone, + company?.poaLocation, + company?.poaAddress, + ].some((v) => v?.trim()); // Shared with the portal (buildCompanyIdentityState) — same derivation, so // this page can never disagree with the rule the API actually enforces. const identityState = company?.identity; @@ -645,9 +655,7 @@ export default function CustomerDetailPage() { const hasEtradeRecord = Boolean(company?.licenceNumber?.trim()); // A freight forwarder acts on other companies' behalf, so its PoA — details // and DARS delegation paper both — is mandatory rather than optional. - const poaMandatory = (company?.companyProfiles ?? []).some( - (p) => p.type === "freight_forwarder", - ); + const poaMandatory = profiles.some((p) => p.type === "freight_forwarder"); const delegationMissing = company?.identity?.poaDeclared === "yes" && poaLive.length === 0; @@ -685,8 +693,9 @@ export default function CustomerDetailPage() { ]} backTo="/dashboard/customers" title={company.name} - subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : "" - }`} + subtitle={`TIN ${company.tin}${ + company.country ? ` · ${company.country}` : "" + }`} meta={ @@ -749,7 +758,7 @@ export default function CustomerDetailPage() { items={[ { label: "Profiles", - value: company.companyProfiles.length, + value: profiles.length, icon: IdCard, color: "edr-green", }, @@ -761,9 +770,7 @@ export default function CustomerDetailPage() { : "Pending approval", value: stillOnboarding ? "—" - : company.companyProfiles.filter( - (p) => p.status === "pending", - ).length, + : profiles.filter((p) => p.status === "pending").length, icon: IdCard, color: "yellow", }, @@ -782,407 +789,392 @@ export default function CustomerDetailPage() { ]} /> - - - - Company information - - - - - - - - - - - {/* Why this company's registration was typed rather than - fetched, and why it carries no business licence. */} - - - - - - - - - - - - - - - - - - - - - - eTrade registration - - {hasEtradeRecord ? ( - - Verified with eTrade - - ) : ( - - No eTrade record - - )} - - {hasEtradeRecord && ( - downloadTinRecord(company)} - > - - - )} - - {hasEtradeRecord ? ( - - - - - - - - - - - - - - ) : ( - - No eTrade registration record on file for this customer's - TIN. - - )} - - - - - - - - Owner identity - - {identityState?.subject === "owner" && ( - - Verifies for this company - - )} - {ownerIdentity?.verified ? ( - - Fayda verified - - ) : ( - - Not verified - - )} - - - {/* THE check: is the owner the company put forward the person - the eTrade licence actually names? Advisory — eTrade and - Fayda transliterate Amharic names differently, so this is a - prompt to look, not a verdict. */} - {identityState?.ownerMatchesEtrade === false ? ( - } - title="Does not match the eTrade licence" - > - The licence names{" "} - {identityState.etradeManagerName}, but this - company recorded {company.ownerName}. - - ) : identityState?.ownerMatchesEtrade === true ? ( - - Matches the eTrade licence - - ) : company.cooperative ? ( - - A co-operative union or farm holds no trade licence, so - there is no eTrade record to check the owner against. - - ) : ( - - No eTrade manager name on file to compare against. - - )} - {ownerIdentity?.verified ? ( - - - - - - - - - - - ) : ( - - {ownerIdentity?.passportNumber - ? `Not Fayda verified — identified by passport ${ownerIdentity.passportNumber}.` - : "The company owner has not verified their identity with Fayda."} - - )} - - - - - - - - - Power of Attorney - - {poaMandatory && ( - - Required for freight forwarder - - )} - - {delegationMissing ? ( - - DARS delegation paper missing - - ) : poaLive.length > 0 ? ( - - DARS delegation paper on file - - ) : ( - - Not provided - - )} - - - {hasPoaDetails ? ( - - {poaFields.map((f) => ( - - ))} - - {poaIdentity?.verified && ( - <> + + {/* Company facts — the wide column. People live in the narrow one + beside it, so nothing about a person is stated twice. */} + + + + + + Company information + + + + + + {/* Why this company's registration was typed rather + than fetched, and why it carries no licence. */} + + + + + + - - - )} - - ) : ( - - No Power of Attorney representative recorded for this - customer. - - )} + + + - - - - - DARS delegation paper - - - {documentsQuery.isLoading ? ( - - - - Loading documents… - - - ) : documentsQuery.isError ? ( - - - Failed to load documents. - - void documentsQuery.refetch()} - > - Retry - - - ) : poaDocuments.length === 0 ? ( - - No DARS delegation paper uploaded. - - ) : ( - poaDocuments.map((doc) => ( - - - - - void fetchViewableFile(doc.id, doc.name).then(view) - } - > - {doc.name} - - - {formatBytes(doc.size)} ·{" "} - {formatDate(doc.uploadedAt)} + + + + + + eTrade registration - {doc.code === POA_DELEGATION_PENDING_CODE && ( - - Pending approval + {hasEtradeRecord ? ( + + Verified with eTrade + + ) : ( + + No eTrade record )} - + {hasEtradeRecord && ( - void fetchViewableFile(doc.id, doc.name).then(view) - } - > - - - - void downloadBookingFile(doc.id, doc.name) - } - variant="subtle" - color="gray" - aria-label={`Download ${doc.name}`} + variant="default" + aria-label="Download TIN record" + onClick={() => downloadTinRecord(company)} > - + )} - )) - )} - - - + {hasEtradeRecord ? ( + + + + + + + + + + + + + + ) : ( + + No eTrade registration record on file for this + customer's TIN. + + )} + + - - - - - Role profiles - - - - - - - - - - + + + + + Role profiles + + + + {/* Narrower than the old full-width layout — the table + shares the row with the people column now. */} + + + + + + + + + + + {/* People: owner, then power of attorney, then contact person — + the order a reviewer checks them in. */} + + + } + verified={Boolean(ownerIdentity?.verified)} + badges={ + <> + {identityState?.subject === "owner" && ( + + Verifies for this company + + )} + {identityState?.ownerMatchesEtrade === true && ( + + Matches eTrade licence + + )} + + } + notice={ + /* THE check: is the owner the company put forward the + person the eTrade licence actually names? Advisory — + eTrade and Fayda transliterate Amharic names + differently, so this is a prompt to look, not a + verdict. */ + identityState?.ownerMatchesEtrade === false ? ( + } + title="Does not match the eTrade licence" + > + + The licence names{" "} + {identityState.etradeManagerName}, + but this company recorded{" "} + {company.ownerName ?? "nobody"}. + + + ) : !ownerIdentity?.verified && + ownerIdentity?.passportNumber ? ( + + Identified by passport rather than Fayda. + + ) : null + } + fields={[ + { + label: "Name", + value: company.ownerName ?? ownerIdentity?.name, + }, + { + label: "Email", + value: company.ownerEmail ?? ownerIdentity?.email, + }, + { + label: "Phone", + value: company.ownerPhone ?? ownerIdentity?.phone, + }, + { + label: "Passport number", + value: ownerIdentity?.passportNumber, + }, + { label: "Address", value: ownerIdentity?.address }, + { label: "Birthdate", value: ownerIdentity?.birthdate }, + { label: "Gender", value: ownerIdentity?.gender }, + { + label: "Verified on", + value: ownerIdentity?.verifiedAt + ? formatDate(ownerIdentity.verifiedAt) + : null, + }, + ]} + emptyMessage="No owner recorded for this company." + /> + + } + verified={ + // No PoA at all → no badge, rather than a "not verified" + // that reads as a problem where none exists. + hasPoaDetails || identityState?.poaDeclared === "yes" + ? Boolean(poaIdentity?.verified) + : undefined + } + badges={ + <> + {identityState?.subject === "poa" && ( + + Verifies for this company + + )} + {poaMandatory && ( + + Required for freight forwarder + + )} + {delegationMissing && ( + + Delegation paper missing + + )} + + } + fields={[ + { label: "Name", value: company.poaName }, + { label: "Email", value: company.poaEmail }, + { label: "Phone", value: company.poaPhone }, + { label: "Location", value: company.poaLocation }, + { label: "Address", value: company.poaAddress }, + { label: "Birthdate", value: poaIdentity?.birthdate }, + { label: "Gender", value: poaIdentity?.gender }, + { + label: "Verified on", + value: poaIdentity?.verifiedAt + ? formatDate(poaIdentity.verifiedAt) + : null, + }, + ]} + emptyMessage="No representative recorded for this customer." + > + + + DARS delegation paper + + + {documentsQuery.isLoading ? ( + + + + Loading… + + + ) : documentsQuery.isError ? ( + + + Failed to load documents. + + void documentsQuery.refetch()} + > + Retry + + + ) : poaDocuments.length === 0 ? ( + + Not uploaded. + + ) : ( + poaDocuments.map((doc) => ( + + + + + void fetchViewableFile(doc.id, doc.name).then( + view, + ) + } + > + {doc.name} + + + void fetchViewableFile(doc.id, doc.name).then( + view, + ) + } + > + + + + void downloadBookingFile(doc.id, doc.name) + } + > + + + + + + {formatBytes(doc.size)} ·{" "} + {formatDate(doc.uploadedAt)} + + {doc.code === POA_DELEGATION_PENDING_CODE && ( + + Pending approval + + )} + + + )) + )} + + + + } + fields={[ + { label: "Name", value: company.contactPersonName }, + { label: "Phone", value: company.contactPersonPhone }, + ]} + emptyMessage="No contact person recorded." + /> + + + @@ -1198,9 +1190,9 @@ export default function CustomerDetailPage() { error={ bookingsQuery.isError ? { - message: "Failed to load bookings.", - onRetry: () => void bookingsQuery.refetch(), - } + message: "Failed to load bookings.", + onRetry: () => void bookingsQuery.refetch(), + } : undefined } /> @@ -1220,9 +1212,9 @@ export default function CustomerDetailPage() { error={ documentsQuery.isError ? { - message: "Failed to load documents.", - onRetry: () => void documentsQuery.refetch(), - } + message: "Failed to load documents.", + onRetry: () => void documentsQuery.refetch(), + } : undefined } /> @@ -1297,9 +1289,9 @@ export default function CustomerDetailPage() { error={ paymentsQuery.isError ? { - message: "Failed to load payments.", - onRetry: () => void paymentsQuery.refetch(), - } + message: "Failed to load payments.", + onRetry: () => void paymentsQuery.refetch(), + } : undefined } /> @@ -1320,9 +1312,9 @@ export default function CustomerDetailPage() { error={ invoicesQuery.isError ? { - message: "Failed to load invoices.", - onRetry: () => void invoicesQuery.refetch(), - } + message: "Failed to load invoices.", + onRetry: () => void invoicesQuery.refetch(), + } : undefined } pagination={{ From 02a264930b4db14ffac3a1fbd37cf63addb77afc Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Wed, 12 Aug 2026 13:04:18 +0300 Subject: [PATCH 3/4] fix: consolidate stamp settings into single company stamp page --- apps/edr-freight-web/backoffice/src/App.tsx | 25 +++++++++++-------- .../components/layout/sidebar-sections.tsx | 14 +++++------ ...sPage.tsx => CompanyStampSettingsPage.tsx} | 22 ++++++++++------ .../backoffice/src/user-management/route.tsx | 23 ++++++++++++++++- 4 files changed, 57 insertions(+), 27 deletions(-) rename apps/edr-freight-web/backoffice/src/pages/settings/{InvoiceStampSettingsPage.tsx => CompanyStampSettingsPage.tsx} (70%) diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f48f302a1..83cf82909 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -51,8 +51,7 @@ import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing"; import NoAccessPage from "./pages/NoAccessPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; -import StampSettings from "./record-management/components/Settings/uploadTeeterandSingature"; -import InvoiceStampSettingsPage from "./pages/settings/InvoiceStampSettingsPage"; +import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import PortalContentPage from "./pages/portal_content/PortalContentPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; @@ -786,24 +785,28 @@ const App = () => { } /> + {/* + The ONE company stamp, for every generated document. The per-officer + teeter (ማህተም) that used to sit beside it at /dashboard/stamp-settings + now lives at /user-management/teeter-and-signature — it is a different + thing (an individual's approval stamp), and pairing the two here was + the duplication. + */} - - - } - /> - - + } /> + {/* Old URL kept alive so existing links/bookmarks do not 404. */} + } + /> , - permission: FREIGHT_PERMS.settings.stamp.view, - }, - { - label: "Invoice stamp", - href: "/dashboard/invoice-stamp-settings", - icon: , + icon: , permission: FREIGHT_PERMS.settings.invoiceStamp.view, }, { diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/InvoiceStampSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/CompanyStampSettingsPage.tsx similarity index 70% rename from apps/edr-freight-web/backoffice/src/pages/settings/InvoiceStampSettingsPage.tsx rename to apps/edr-freight-web/backoffice/src/pages/settings/CompanyStampSettingsPage.tsx index 407218d70..730458f2a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/settings/InvoiceStampSettingsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/settings/CompanyStampSettingsPage.tsx @@ -17,10 +17,16 @@ import { } from "@/hooks/useStampSettings"; /** - * The one company stamp/seal stamped onto every generated invoice/receipt - * PDF (InvoiceDocumentService). Single global image — no per-employee choice. + * The ONE company stamp/seal, read by every document path server-side via + * StampSettingsService: invoices and receipts (InvoiceDocumentService), + * warehouse release + handover papers, and the EDR side of contract signature + * blocks. Single global image — no per-employee choice, and staff never upload + * one when signing. + * + * Not to be confused with the per-officer teeter (ማህተም) at + * /user-management/teeter-and-signature, which is genuinely per-person. */ -export default function InvoiceStampSettingsPage() { +export default function CompanyStampSettingsPage() { const { data, isLoading } = useStampSettingsQuery(); const setStamp = useSetStamp(); const clearStamp = useClearStamp(); @@ -47,11 +53,13 @@ export default function InvoiceStampSettingsPage() {
- Invoice stamp + Company stamp - Stamped onto every generated invoice and receipt PDF. Replacing it - here changes it everywhere at once — there is no per-invoice or - per-user choice. + The single EDR seal, applied to every generated document — invoices + and receipts, warehouse release and handover papers, and the EDR + side of signed contracts. Replacing it here changes it everywhere at + once; there is no per-document, per-invoice or per-employee choice. + Staff do not upload their own. diff --git a/apps/edr-freight-web/backoffice/src/user-management/route.tsx b/apps/edr-freight-web/backoffice/src/user-management/route.tsx index f1e2a8611..ed6b13413 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/route.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/route.tsx @@ -3,7 +3,7 @@ import { Navigate, Outlet, Route } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; import { NO_ACCESS_PATH, resolveLandingPath } from "@/lib/landing"; -import { isSuperAdmin } from "@/lib/permissions"; +import { FREIGHT_PERMS, isSuperAdmin } from "@/lib/permissions"; import { WithPermission } from "@/shared/hooks/useHas"; import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers"; import TemplatePage from "@/super-admin/components/templates/components/templates"; @@ -44,6 +44,8 @@ import { SidebarProvider } from "@/shared/common/ui/sidebar"; import { AuthProvider as UmAuthProvider } from "@/shared/context/AuthContext"; import { PermissionProvider } from "@/shared/context/PermissionContext"; import UserManagementPage from "@/pages/UserManagementPage"; +import { RequirePermission } from "@/components/auth/RequirePermission"; +import UploadTeeterAndSignature from "@/record-management/components/Settings/uploadTeeterandSingature"; /** * Provider shell for the vendored IAM UI. Feeds its Auth + Permission contexts @@ -138,6 +140,25 @@ export function UserManagementRoutes(): ReactElement { path="user-management/position-management" element={} /> + {/* + The per-officer teeter (ማህተም) + signature upload. This + is NOT the company stamp: it is the individual approval + stamp a record officer applies to records, locale-aware + (am/en) and genuinely per-person. It used to sit at + /dashboard/stamp-settings under Settings, next to the + single global company stamp, which read as duplication. + Permission gate unchanged from that route. + */} + + + + } + /> } From 2a816912fcae7842b1dbcb011a18d7378eae1a42 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Wed, 12 Aug 2026 13:18:29 +0300 Subject: [PATCH 4/4] fix(freight-permissions): split company stamp from per-officer teeter permission --- .../stamp-settings.controller.ts | 6 ++-- .../src/seed/freight-permissions.registry.ts | 31 ++++++++++++------- apps/edr-freight-web/backoffice/src/App.tsx | 2 +- .../components/layout/sidebar-sections.tsx | 2 +- .../backoffice/src/lib/permissions.ts | 13 +++++--- .../backoffice/src/user-management/route.tsx | 8 +++-- .../src/logging/request-log.middleware.ts | 6 +--- 7 files changed, 40 insertions(+), 28 deletions(-) diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.controller.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.controller.ts index f03d55058..1ba60139e 100644 --- a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.controller.ts @@ -15,21 +15,21 @@ export class StampSettingsController { constructor(private readonly service: StampSettingsService) {} @Get() - @BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.view, FREIGHT_PERMS.admin]) + @BookingStaff([FREIGHT_PERMS.settings.stamp.view, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Current company stamp used on invoice/receipt PDFs" }) get() { return this.service.getView(); } @Put() - @BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin]) + @BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Replace the company stamp" }) update(@Body() dto: UpdateStampSettingDto, @CurrentUser() user: TCurrentUser) { return this.service.setStamp(dto.stampImageBase64, user?.id ?? null); } @Delete() - @BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin]) + @BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Clear the company stamp (invoices fall back to the plain seal)", }) diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 67df922b5..f0b719d63 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1190,22 +1190,28 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [ perm( "b4b00002-0001-4000-8000-000000000001", "edr_freight_app:settings:stamp:view", - "View stamp settings", + "View the company stamp", ), perm( "b4b00002-0001-4000-8000-000000000002", "edr_freight_app:settings:stamp:manage", - "Manage stamp settings", + "Manage the company stamp", ), + // The per-officer approval teeter (ማህተም) — an individual's own stamp + + // signature, not the company seal. It used to ride on settings:stamp:*, which + // now gates the ONE company stamp; this key was split out when the two were + // untangled. `settings:invoice_stamp:*` retired at the same time: it gated the + // company stamp before the fold and is deliberately left orphaned in any DB + // that already seeded it (the seeder upserts by key and never deletes). perm( "b4b00003-0001-4000-8000-000000000001", - "edr_freight_app:settings:invoice_stamp:view", - "View invoice stamp settings", + "edr_freight_app:settings:teeter:view", + "View own approval teeter and signature", ), perm( "b4b00003-0001-4000-8000-000000000002", - "edr_freight_app:settings:invoice_stamp:manage", - "Manage invoice stamp settings", + "edr_freight_app:settings:teeter:manage", + "Manage own approval teeter and signature", ), perm( "b4c00001-0001-4000-8000-000000000001", @@ -1907,15 +1913,18 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:dropdown:view", manage: "edr_freight_app:settings:dropdown:manage", }, + // The ONE company stamp/seal, applied to every generated document + // (invoices, receipts, warehouse papers, the EDR side of contracts). stamp: { view: "edr_freight_app:settings:stamp:view", manage: "edr_freight_app:settings:stamp:manage", }, - // Company stamp/seal image stamped onto invoice/receipt PDFs — separate - // from `stamp` above, which is the per-employee approval-record teeter. - invoiceStamp: { - view: "edr_freight_app:settings:invoice_stamp:view", - manage: "edr_freight_app:settings:invoice_stamp:manage", + // The per-officer approval teeter (ማህተም) + signature — genuinely per-person, + // and NOT the company seal above. Retired: `invoiceStamp`, which used to + // gate the company stamp before the two were untangled. + teeter: { + view: "edr_freight_app:settings:teeter:view", + manage: "edr_freight_app:settings:teeter:manage", }, exchangeRate: { view: "edr_freight_app:settings:exchange_rate:view", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 83cf82909..64f91da72 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -796,7 +796,7 @@ const App = () => { path="stamp-settings" element={ diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index c06b5a7e1..95608b6db 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -493,7 +493,7 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] label: "Company stamp", href: "/dashboard/stamp-settings", icon: , - permission: FREIGHT_PERMS.settings.invoiceStamp.view, + permission: FREIGHT_PERMS.settings.stamp.view, }, { label: "Contract templates", diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 26b46e37b..c69aa5d1b 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -328,15 +328,18 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:dropdown:view", manage: "edr_freight_app:settings:dropdown:manage", }, + // The ONE company stamp/seal, applied to every generated document + // (invoices, receipts, warehouse papers, the EDR side of contracts). stamp: { view: "edr_freight_app:settings:stamp:view", manage: "edr_freight_app:settings:stamp:manage", }, - // Company stamp/seal image stamped onto invoice/receipt PDFs — separate - // from `stamp` above, which is the per-employee approval-record teeter. - invoiceStamp: { - view: "edr_freight_app:settings:invoice_stamp:view", - manage: "edr_freight_app:settings:invoice_stamp:manage", + // The per-officer approval teeter (ማህተም) + signature — genuinely per-person, + // and NOT the company seal above. Retired: `invoiceStamp`, which used to + // gate the company stamp before the two were untangled. + teeter: { + view: "edr_freight_app:settings:teeter:view", + manage: "edr_freight_app:settings:teeter:manage", }, exchangeRate: { view: "edr_freight_app:settings:exchange_rate:view", diff --git a/apps/edr-freight-web/backoffice/src/user-management/route.tsx b/apps/edr-freight-web/backoffice/src/user-management/route.tsx index ed6b13413..a336b3f9f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/route.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/route.tsx @@ -147,13 +147,17 @@ export function UserManagementRoutes(): ReactElement { (am/en) and genuinely per-person. It used to sit at /dashboard/stamp-settings under Settings, next to the single global company stamp, which read as duplication. - Permission gate unchanged from that route. + + Gated by settings:teeter:*, split out of settings:stamp:* + when the two were untangled — settings:stamp:* now means + the company stamp, so anyone who held it for the teeter + needs the new key granted. */} diff --git a/packages/api-common/src/logging/request-log.middleware.ts b/packages/api-common/src/logging/request-log.middleware.ts index b25463161..5d9ead8b0 100644 --- a/packages/api-common/src/logging/request-log.middleware.ts +++ b/packages/api-common/src/logging/request-log.middleware.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { Injectable, Logger, NestMiddleware } from "@nestjs/common"; +import { Injectable, NestMiddleware } from "@nestjs/common"; import { RequestLogContext, @@ -58,8 +58,6 @@ const userId = (req: LoggedRequest): string | undefined => { */ @Injectable() export class RequestLogMiddleware implements NestMiddleware { - private readonly logger = new Logger("HTTP"); - use(req: LoggedRequest, res: LoggedResponse, next: () => void): void { const start = Date.now(); const requestId = header(req, "x-request-id") ?? randomUUID(); @@ -85,8 +83,6 @@ export class RequestLogMiddleware implements NestMiddleware { const status = res.statusCode; const durationMs = Date.now() - start; - this.logger.log(`${req.method} ${url} ${status} ${durationMs}ms`); - const line = { ...ctx, // Fields the Nest console prefix used to carry. They are IN the JSON