Merge pull request #788 from Tria-plc/freight/feat/chat-app

Freight/feat/chat app added attachment
This commit is contained in:
Nathnael Wondisha
2026-07-20 11:23:16 +03:00
committed by GitHub
69 changed files with 5390 additions and 497 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

@@ -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. */