Implement intercity document handling and rejection notes for contracts

This commit is contained in:
Marshal
2026-07-21 10:20:36 +00:00
226 changed files with 13854 additions and 2973 deletions

View File

@@ -1,5 +1,6 @@
export * from "./payments";
export * from "./payment-messaging";
export * from "./support-attachments";
export interface BaseEntity {
id: string;

View File

@@ -0,0 +1,73 @@
/**
* Rules shared by the freight and passenger support-chat attachment flows.
*
* The two chat backends are independent implementations (freight: TypeORM +
* polymorphic `FileRecord`; passenger: Prisma + `SupportAttachment`), but the
* *contract* a client codes against — what may be uploaded, how large, how the
* preview URL behaves — must not drift between them. Keep the limits here so
* both APIs validate identically and all four web apps can render one consistent
* "file too large / type not allowed" message.
*/
/** `FileRecord.resource` discriminator for freight chat attachments. */
export const SUPPORT_ATTACHMENT_RESOURCE = "support_message";
/** Per-file ceiling. Enforced server-side; the UI pre-checks to fail fast. */
export const SUPPORT_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
/** Max files on a single message. */
export const SUPPORT_ATTACHMENT_MAX_PER_MESSAGE = 10;
/**
* How long a minted preview URL stays valid. Long enough that an open thread
* doesn't rot mid-read, short enough that a leaked URL isn't a durable grant.
*/
export const SUPPORT_ATTACHMENT_URL_TTL_SECONDS = 60 * 60;
/**
* Types accepted on a chat message.
*
* Deliberately NARROWER than `FilesService.ALLOWED_UPLOAD_MIME` (which also
* serves generated PDFs and scanned business documents at 25MB). Chat is
* user-to-user, so the blast radius of a bad file is another human clicking it.
*
* `image/svg+xml` is excluded on purpose and must stay excluded: an SVG is
* executable markup, and previewing one inline (`<img>` is safe, but an
* `<iframe>`/direct navigation is not) executes any script it carries under the
* serving origin. Nothing in chat needs vector uploads.
*/
export const SUPPORT_ATTACHMENT_ALLOWED_MIME: readonly string[] = [
// images (previewable inline)
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
// documents
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"text/csv",
"text/plain",
];
/** Image subset — these are the ones worth rendering as a thumbnail. */
export const SUPPORT_ATTACHMENT_IMAGE_MIME: readonly string[] = [
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
];
export function isSupportAttachmentImage(mimeType: string): boolean {
return SUPPORT_ATTACHMENT_IMAGE_MIME.includes(mimeType);
}
export function isSupportAttachmentAllowed(mimeType: string): boolean {
return SUPPORT_ATTACHMENT_ALLOWED_MIME.includes(mimeType);
}
/** `accept` attribute for a file input / paste target. */
export const SUPPORT_ATTACHMENT_ACCEPT =
SUPPORT_ATTACHMENT_ALLOWED_MIME.join(",");

View File

@@ -449,6 +449,8 @@ export interface ContractClearanceView {
/** Customs risk level assigned by GL ET (import; visible to the customer). */
riskLevel?: string | null;
riskAssignedAt?: string | null;
/** Every risk decision, oldest first; the last entry is the current level. */
riskHistory?: RiskAssignmentRecord[];
/** Post-arrival additional duty/tax round (import). */
secondDuty?: ClearanceSecondDuty | null;
importReleaseGranted?: boolean;
@@ -482,9 +484,27 @@ export type MilestoneStatus = "PENDING" | "COMPLETED" | "SKIPPED";
export const CUSTOMS_RISK_LEVELS = ["GREEN", "YELLOW", "RED"] as const;
export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number];
/**
* One customs risk decision. The level stays correctable until duty is advised
* off it and is customer-visible, so every assignment is kept rather than
* overwritten.
*/
export interface RiskAssignmentRecord {
level: CustomsRiskLevel;
/** The level this replaced; absent on the first assignment. */
previousLevel?: CustomsRiskLevel;
assignedAt: string;
assignedByUserId?: string | null;
/** Display name resolved at assignment time, so the trail never shows a UUID. */
assignedBy?: string | null;
note?: string | null;
}
/** Structured payload carried by RISK_ASSIGNED / DUTY_TAXES_ADVISED milestones. */
export interface MilestoneMetadata {
riskLevel?: CustomsRiskLevel;
/** Every risk decision, oldest first; the last entry matches `riskLevel`. */
riskHistory?: RiskAssignmentRecord[];
dutyAmount?: number;
dutyCurrency?: string;
declarationSerial?: string;

View File

@@ -0,0 +1,97 @@
/**
* Ethiopia's first-level administrative divisions: regional states plus the two
* chartered city administrations.
*
* NOTE — this list churned recently and should be re-confirmed with EDR before
* being treated as final. SNNPR was progressively dissolved: Sidama split off in
* 2020, South West Ethiopia Peoples' in 2021, and the remainder became Central
* Ethiopia and South Ethiopia in 2023. Records created before those splits may
* still carry "SNNPR" or "Southern Nations".
*/
export const ETHIOPIAN_REGIONS = [
"Addis Ababa",
"Afar",
"Amhara",
"Benishangul-Gumuz",
"Central Ethiopia",
"Dire Dawa",
"Gambela",
"Harari",
"Oromia",
"Sidama",
"Somali",
"South Ethiopia",
"South West Ethiopia Peoples'",
"Tigray",
] as const;
export type EthiopianRegion = (typeof ETHIOPIAN_REGIONS)[number];
/**
* Free-text region values seen in the wild — eTrade returns uncoded uppercase
* strings, and pre-dropdown rows were hand-typed. Keys are lowercased and
* whitespace-collapsed before lookup, so only spelling/naming variants belong
* here, not case variants.
*/
const REGION_ALIASES: Record<string, EthiopianRegion> = {
// Spelling and transliteration variants.
oromoia: "Oromia",
oromiya: "Oromia",
oromina: "Oromia",
addisabeba: "Addis Ababa",
"addis abeba": "Addis Ababa",
"dire dawa city": "Dire Dawa",
"benishangul gumuz": "Benishangul-Gumuz",
"benshangul-gumuz": "Benishangul-Gumuz",
"beneshangul-gumuz": "Benishangul-Gumuz",
gambella: "Gambela",
tigrai: "Tigray",
tigre: "Tigray",
"south west ethiopia": "South West Ethiopia Peoples'",
"south west ethiopia peoples": "South West Ethiopia Peoples'",
"southwest ethiopia": "South West Ethiopia Peoples'",
// eTrade frequently returns a ZONE in the Region slot. These map the zone back
// to its parent region; the zone itself is preserved in the `zone` field.
"eastern tigray": "Tigray",
"western tigray": "Tigray",
"southern tigray": "Tigray",
"central tigray": "Tigray",
"north wollo": "Amhara",
"south wollo": "Amhara",
"east gojjam": "Amhara",
"west gojjam": "Amhara",
"north shewa": "Amhara",
"east shewa": "Oromia",
"west shewa": "Oromia",
"arsi": "Oromia",
"bale": "Oromia",
"borena": "Oromia",
"jimma": "Oromia",
"gamo": "South Ethiopia",
"gofa": "South Ethiopia",
"wolayita": "South Ethiopia",
};
/** Collapse whitespace and case so lookups are tolerant of formatting noise. */
function canonicalKey(value: string): string {
return value.trim().replace(/\s+/g, " ").toLowerCase();
}
/**
* Map an arbitrary region string (eTrade payload, legacy row, hand entry) onto a
* canonical region. Returns `null` when it cannot be resolved — callers should
* leave the field empty and let the user pick, rather than persisting a guess.
*/
export function normalizeRegion(
value: string | null | undefined,
): EthiopianRegion | null {
if (!value) return null;
const key = canonicalKey(value);
if (!key) return null;
const exact = ETHIOPIAN_REGIONS.find((r) => canonicalKey(r) === key);
if (exact) return exact;
return REGION_ALIASES[key] ?? null;
}

View File

@@ -7,6 +7,7 @@ export * from "./overview";
export * from "./etrade";
export * from "./contracts";
export * from "./clearance-files.catalog";
export * from "./ethiopian-regions.catalog";
export * from "./notifications";
export * from "./booking-window-ws";
export * from "./support-chat";
@@ -780,6 +781,8 @@ export interface ClearanceView {
/** Customs risk level assigned by GL ET (import; visible to the customer). */
riskLevel?: string | null;
riskAssignedAt?: string | null;
/** Every risk decision, oldest first; the last entry is the current level. */
riskHistory?: import("./contracts").RiskAssignmentRecord[];
/** Post-arrival additional duty/tax round (import). */
secondDuty?: import("./contracts").ClearanceSecondDuty | null;
importReleaseGranted?: boolean;

View File

@@ -16,12 +16,37 @@
* constants shared by the gateway (emitter) and both web apps (subscribers).
*/
import { SUPPORT_ATTACHMENT_RESOURCE } from "../common/support-attachments";
/** Who authored a message — the customer side or a backoffice agent. */
export enum SupportAuthorRole {
CUSTOMER = "CUSTOMER",
AGENT = "AGENT",
}
/** `FileRecord.resource` value chat attachments are stored under. */
export { SUPPORT_ATTACHMENT_RESOURCE };
/** A file attached to a support message. */
export interface SupportAttachmentDto {
/**
* FileRecord id. For an authenticated download use
* `GET /support/attachments/:id?download=1` — the generic `GET /files/:id`
* route deliberately refuses chat attachments (it has no ownership check).
*/
id: string;
name: string;
mimeType: string;
/** Bytes. */
size: number;
/**
* Short-lived signed URL for inline preview (`<img src>`), minted per response.
* Expires — see SUPPORT_ATTACHMENT_URL_TTL_SECONDS. Clients must not persist it;
* refetch the thread to renew.
*/
url: string;
}
/** A single chat message on the wire. */
export interface SupportMessageDto {
id: string;
@@ -30,7 +55,9 @@ export interface SupportMessageDto {
authorRole: SupportAuthorRole;
/** Display name of the author, resolved at send time (best-effort). */
authorName?: string | null;
/** Empty string for attachment-only messages. */
body: string;
attachments: SupportAttachmentDto[];
createdAt: string;
}
@@ -54,9 +81,17 @@ export interface SupportConversationDto {
updatedAt: string;
}
/** Post a message. The portal omits the id; the thread is implied by the company. */
/**
* Post a message. The portal omits the id; the thread is implied by the company.
*
* Attachments do not travel in this shape — a message carrying files is sent as
* `multipart/form-data` with a `body` field plus one or more `attachments` file
* parts, so the files are written with `resourceId = message.id` in the same
* request. There is no staging area and therefore no orphan-file GC to run.
*/
export interface SendSupportMessageDto {
body: string;
/** Optional only when the request carries at least one attachment. */
body?: string;
}
/** Agent opens a thread with a company that has none yet. */
@@ -81,6 +116,24 @@ export interface SupportConversationListResult {
unreadCount: number;
}
/**
* A page of messages, walking **backwards** from newest.
*
* Chat pages by keyset, not by offset: an inbound message while an agent is
* scrolled back would shift every offset by one and duplicate/skip rows across
* pages. The cursor pins a fixed point in (createdAt, id), so concurrent inserts
* at the head can't disturb pages already read.
*/
export interface SupportMessageListResult {
/** Oldest-first *within the page*, so a page appends/prepends as a block. */
items: SupportMessageDto[];
/**
* Opaque cursor for the next (older) page; null when the thread's start has
* been reached. Pass back as `before`.
*/
nextCursor: string | null;
}
/** Socket.io event names pushed server → client on the `support-chat` namespace. */
export const SUPPORT_CHAT_WS_EVENTS = {
/** A new message was added to a conversation the socket can see. */

View File

@@ -23,6 +23,33 @@ export enum PassengerSupportSender {
AGENT = "AGENT",
}
/**
* A file attached to a passenger support message.
*
* Structurally identical to the freight `SupportAttachmentDto` — kept as its own
* declaration because the two namespaces are independently versioned and the
* backing stores differ (Prisma `SupportAttachment` here, polymorphic
* `FileRecord` in freight). The upload rules themselves are shared: see
* `SUPPORT_ATTACHMENT_*` in `common/support-attachments`.
*/
export interface PassengerSupportAttachmentDto {
id: string;
name: string;
mimeType: string;
/** Bytes. */
size: number;
/**
* Short-lived signed URL — serves both inline preview and download. Expires
* (see SUPPORT_ATTACHMENT_URL_TTL_SECONDS); clients must not persist it,
* refetch the thread to renew.
*
* There is no API-streamed alternative here, unlike freight: this app has no
* general file endpoint, so the signed URL is the only handle. It is minted
* only into responses the caller was already authorized to receive.
*/
url: string;
}
/** A single chat message on the wire. */
export interface PassengerSupportMessageDto {
id: string;
@@ -30,7 +57,9 @@ export interface PassengerSupportMessageDto {
sender: PassengerSupportSender;
/** Display name of the author, best-effort. */
authorName?: string | null;
/** Empty string for attachment-only messages. */
text: string;
attachments: PassengerSupportAttachmentDto[];
createdAt: string;
}
@@ -74,15 +103,43 @@ export interface CreateGuestSupportConversationDto {
initialMessage: string;
}
/** Post a message into an existing conversation. */
/**
* Post a message into an existing conversation.
*
* As in freight, attachments travel as `multipart/form-data` (a `text` field
* plus `attachments` file parts) rather than as ids in this body, so files are
* persisted against the message that owns them in one request.
*/
export interface SendPassengerSupportMessageDto {
text: string;
/** Optional only when the request carries at least one attachment. */
text?: string;
}
/** The single device-scoped thread for the portal: conversation + its messages. */
/**
* The single device-scoped thread for the portal: conversation + its newest page
* of messages.
*
* `messages` is the *first page only* (newest N, oldest-first within the page) —
* it is not the whole thread. Page backwards with `nextCursor` via the messages
* endpoint, exactly as the backoffice does.
*/
export interface PassengerSupportThreadDto {
conversation: PassengerSupportConversationDto | null;
messages: PassengerSupportMessageDto[];
/** Cursor for the next (older) page; null when the thread's start is loaded. */
nextCursor: string | null;
}
/**
* A page of messages, walking backwards from newest. Keyset — not offset — so a
* message arriving while the reader is scrolled back cannot shift or duplicate
* pages already fetched. See the freight twin for the full rationale.
*/
export interface PassengerSupportMessageListResult {
/** Oldest-first within the page. */
items: PassengerSupportMessageDto[];
/** Pass back as `before`; null at the start of the thread. */
nextCursor: string | null;
}
/** Paginated list envelope for the conversations list endpoints. */