mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Implement intercity document handling and rejection notes for contracts
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
export * from "./payments";
|
||||
export * from "./payment-messaging";
|
||||
export * from "./support-attachments";
|
||||
|
||||
export interface BaseEntity {
|
||||
id: string;
|
||||
|
||||
73
packages/types/src/common/support-attachments.ts
Normal file
73
packages/types/src/common/support-attachments.ts
Normal 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(",");
|
||||
@@ -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;
|
||||
|
||||
97
packages/types/src/freight/ethiopian-regions.catalog.ts
Normal file
97
packages/types/src/freight/ethiopian-regions.catalog.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user