mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge remote-tracking branch 'origin/dev' into tests
This commit is contained in:
@@ -57,6 +57,7 @@
|
||||
"express": "^4.18.2",
|
||||
"helmet": "^8.0.0",
|
||||
"jose": "^5.10.0",
|
||||
"minio": "7.1.3",
|
||||
"pg": "^8.21.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -76,6 +77,7 @@
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Support chat attachments.
|
||||
--
|
||||
-- `SupportMessage.text` becomes nullable so an attachment-only message can say
|
||||
-- "there is no text" instead of smuggling that through an empty string. This is
|
||||
-- a catalog-only change in Postgres — no table rewrite, no long lock.
|
||||
ALTER TABLE "SupportMessage" ALTER COLUMN "text" DROP NOT NULL;
|
||||
|
||||
-- The `attachments` JSONB column has been dead since the init migration: never
|
||||
-- written, never read, absent from every DTO. It is dropped rather than reused —
|
||||
-- an untyped blob gives no file identity, no size accounting, and nothing to
|
||||
-- cascade on delete. Real rows replace it below. (The name is also needed for
|
||||
-- the new relation.)
|
||||
ALTER TABLE "SupportMessage" DROP COLUMN "attachments";
|
||||
|
||||
-- Backs keyset pagination of a thread (newest-first over (createdAt, id)).
|
||||
-- Without it, paging a long thread degrades to a scan per page.
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SupportMessage_conversationId_createdAt_idx" ON "SupportMessage"("conversationId", "createdAt");
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SupportAttachment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"messageId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SupportAttachment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SupportAttachment_messageId_idx" ON "SupportAttachment"("messageId");
|
||||
|
||||
-- AddForeignKey
|
||||
-- CASCADE: an attachment has no meaning without its message. (Object bytes in
|
||||
-- MinIO are not reaped by this — deleting messages is not a flow that exists.)
|
||||
ALTER TABLE "SupportAttachment" ADD CONSTRAINT "SupportAttachment_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "SupportMessage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
-- Widen PaymentIntent.amountMinor from integer to double precision so fractional
|
||||
-- charge amounts (e.g. 1700.49 after ETB->DJF FX conversion) mirror the
|
||||
-- edr_payment.payment_intent.amount_minor source of truth instead of truncating.
|
||||
ALTER TABLE "passenger"."PaymentIntent" ALTER COLUMN "amountMinor" SET DATA TYPE DOUBLE PRECISION;
|
||||
@@ -621,7 +621,7 @@ model PaymentMethod {
|
||||
model PaymentIntent {
|
||||
id String @id @default(uuid())
|
||||
bookingId String @unique
|
||||
amountMinor Int
|
||||
amountMinor Float
|
||||
currency String @default("ETB")
|
||||
method PaymentMethodType
|
||||
provider String?
|
||||
@@ -917,10 +917,37 @@ model SupportMessage {
|
||||
id String @id @default(uuid())
|
||||
conversationId String
|
||||
sender SupportSender
|
||||
text String
|
||||
attachments Json?
|
||||
/// NULL for an attachment-only message — absence of text is representable
|
||||
/// rather than smuggled through "". The DTO maps NULL -> "".
|
||||
text String?
|
||||
createdAt DateTime @default(now())
|
||||
conversation SupportConversation @relation(fields: [conversationId], references: [id])
|
||||
attachments SupportAttachment[]
|
||||
/// Backs keyset pagination of a thread (newest-first over (createdAt, id)).
|
||||
/// Without it, paging a long thread degrades to a scan per page.
|
||||
@@index([conversationId, createdAt])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
/// A file posted on a support message.
|
||||
///
|
||||
/// The freight side stores the equivalent in its polymorphic `freight.files`
|
||||
/// table; this app has no such table (and no TypeORM), so chat attachments get a
|
||||
/// purpose-built model rather than a shared one. Bytes live in MinIO — `url` is
|
||||
/// the unsigned object path, signed on read for preview.
|
||||
model SupportAttachment {
|
||||
id String @id @default(uuid())
|
||||
messageId String
|
||||
name String
|
||||
mimeType String
|
||||
/// Bytes.
|
||||
size Int
|
||||
/// Unsigned MinIO object URL. Not directly fetchable by a browser — the API
|
||||
/// mints a short-lived signed URL per response.
|
||||
url String
|
||||
createdAt DateTime @default(now())
|
||||
message SupportMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
@@index([messageId])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,17 @@ export class ReportsController {
|
||||
return this.service.getOccupancyBySchedule(scheduleId);
|
||||
}
|
||||
|
||||
@Get("payment-discrepancy")
|
||||
@ApiOperation({ summary: "Payment discrepancy report — bookings where paid amount is less than the fare. Pass `search` to look up a specific PNR or ticket number." })
|
||||
getPaymentDiscrepancy(
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
@Query('sortBy') sortBy?: string,
|
||||
@Query('search') search?: string,
|
||||
) {
|
||||
return this.service.getPaymentDiscrepancyReport({ from, to, sortBy, search });
|
||||
}
|
||||
|
||||
@Get(":reportId")
|
||||
@ApiOperation({ summary: "Get report by ID" })
|
||||
getReport(@Param("reportId") reportId: string) {
|
||||
|
||||
@@ -591,6 +591,258 @@ export class ReportsService {
|
||||
};
|
||||
}
|
||||
|
||||
async getPaymentDiscrepancyReport(params: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
sortBy?: string;
|
||||
search?: string;
|
||||
}) {
|
||||
// Load exchange rates once — we need DJF→ETB (and any other non-ETB currencies).
|
||||
// Keep only the most-recent rate per pair (rates are ordered desc by effectiveDate).
|
||||
const rateRows = await this.prisma.currencyExchangeRate.findMany({
|
||||
where: { toCurrency: 'ETB' as any },
|
||||
orderBy: { effectiveDate: 'desc' },
|
||||
});
|
||||
const rateToEtb = new Map<string, number>();
|
||||
for (const r of rateRows) {
|
||||
if (!rateToEtb.has(r.fromCurrency)) {
|
||||
rateToEtb.set(r.fromCurrency, Number(r.rate));
|
||||
}
|
||||
}
|
||||
|
||||
// Convert any minor amount to its ETB equivalent using stored exchange rates.
|
||||
// b.totalMinor is the booking's canonical ETB amount (always stored in ETB),
|
||||
// so callers should pass that directly rather than converting displayTotalMinor.
|
||||
const toEtbMinor = (minor: number, currency: string): number => {
|
||||
if (currency === 'ETB') return minor;
|
||||
const rate = rateToEtb.get(currency);
|
||||
// If no rate is on file fall back to the raw value (avoids silently hiding
|
||||
// cross-currency bookings, at the cost of an approximate comparison).
|
||||
return rate ? Math.round(minor * rate) : minor;
|
||||
};
|
||||
|
||||
if (params.search?.trim()) {
|
||||
return this.getDiscrepancyForRef(params.search.trim(), toEtbMinor);
|
||||
}
|
||||
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
if (params.from) dateFilter.gte = new Date(params.from + 'T00:00:00.000Z');
|
||||
if (params.to) dateFilter.lte = new Date(params.to + 'T23:59:59.999Z');
|
||||
|
||||
const seatSelect = {
|
||||
where: { leg: 1 },
|
||||
orderBy: [
|
||||
{ seat: { coach: { number: 'asc' as const } } },
|
||||
{ seat: { seatNumber: 'asc' as const } },
|
||||
],
|
||||
select: {
|
||||
passengerName: true,
|
||||
passengerCategory: true,
|
||||
seatLabelSnapshot: true,
|
||||
fareMinor: true,
|
||||
displayFareMinor: true,
|
||||
displayCurrency: true,
|
||||
seat: {
|
||||
select: {
|
||||
seatNumber: true,
|
||||
coach: { select: { number: true, coachType: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any },
|
||||
paymentIntent: { status: 'SUCCEEDED' },
|
||||
...(Object.keys(dateFilter).length > 0 && { createdAt: dateFilter }),
|
||||
},
|
||||
include: {
|
||||
paymentIntent: { select: { amountMinor: true, currency: true, paidAt: true } },
|
||||
schedule: {
|
||||
include: {
|
||||
originStation: { select: { name: true, code: true, city: true } },
|
||||
destinationStation: { select: { name: true, code: true, city: true } },
|
||||
},
|
||||
},
|
||||
seats: seatSelect,
|
||||
passenger: {
|
||||
select: { user: { select: { phone: true, fullName: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const rows = bookings
|
||||
.map(b => {
|
||||
const pi = b.paymentIntent!;
|
||||
|
||||
// Display amounts shown to the passenger (may be in DJF).
|
||||
const actualMinor = b.displayTotalMinor ?? b.totalMinor;
|
||||
const actualCurrency = (b.displayCurrency as string | null) ?? b.currency;
|
||||
|
||||
const paidMinor = pi.amountMinor;
|
||||
const paidCurrency = pi.currency;
|
||||
|
||||
// b.totalMinor is always in ETB. Convert the paid amount to ETB for an
|
||||
// apples-to-apples comparison regardless of which currency was used at checkout.
|
||||
const owedEtb = b.totalMinor;
|
||||
const paidEtb = toEtbMinor(paidMinor, paidCurrency);
|
||||
const balanceMinor = owedEtb - paidEtb;
|
||||
const balanceCurrency = 'ETB';
|
||||
|
||||
const firstSeat = b.seats[0];
|
||||
const passengers = this.buildSeatPassengers(b.seats, actualCurrency);
|
||||
return {
|
||||
pnr: b.bookingRef,
|
||||
passengerName: firstSeat?.passengerName ?? b.passenger?.user?.fullName ?? '—',
|
||||
phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—',
|
||||
bookingDate: b.createdAt,
|
||||
origin: b.schedule.originStation,
|
||||
destination: b.schedule.destinationStation,
|
||||
departureAt: b.schedule.departureAt,
|
||||
seatType: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—',
|
||||
coachNumber: firstSeat?.seat?.coach?.number ?? null,
|
||||
actualMinor,
|
||||
actualCurrency,
|
||||
paidMinor,
|
||||
paidCurrency,
|
||||
balanceMinor,
|
||||
balanceCurrency,
|
||||
passengerCount: passengers.length,
|
||||
passengers,
|
||||
};
|
||||
})
|
||||
.filter(r => r.balanceMinor > 0);
|
||||
|
||||
if (params.sortBy === 'departure') {
|
||||
rows.sort((a, b) => new Date(a.departureAt).getTime() - new Date(b.departureAt).getTime());
|
||||
} else {
|
||||
rows.sort((a, b) => b.balanceMinor - a.balanceMinor);
|
||||
}
|
||||
|
||||
const totalBalanceEtbMinor = rows.reduce((sum, r) => sum + r.balanceMinor, 0);
|
||||
|
||||
return { total: rows.length, totalBalanceEtbMinor, rows };
|
||||
}
|
||||
|
||||
private async getDiscrepancyForRef(
|
||||
search: string,
|
||||
toEtbMinor: (minor: number, currency: string) => number,
|
||||
) {
|
||||
let bookingId: string | null = null;
|
||||
const byPnr = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef: search.toUpperCase() },
|
||||
select: { id: true },
|
||||
});
|
||||
if (byPnr) {
|
||||
bookingId = byPnr.id;
|
||||
} else {
|
||||
const ticket = await this.prisma.ticket.findFirst({
|
||||
where: { barcodePayload: search },
|
||||
select: { bookingId: true },
|
||||
});
|
||||
bookingId = ticket?.bookingId ?? null;
|
||||
}
|
||||
|
||||
if (!bookingId) {
|
||||
return { total: 0, totalBalanceEtbMinor: 0, rows: [], notFound: true };
|
||||
}
|
||||
|
||||
const b = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
paymentIntent: { select: { amountMinor: true, currency: true, paidAt: true, status: true } },
|
||||
schedule: {
|
||||
include: {
|
||||
originStation: { select: { name: true, code: true, city: true } },
|
||||
destinationStation: { select: { name: true, code: true, city: true } },
|
||||
},
|
||||
},
|
||||
seats: {
|
||||
where: { leg: 1 },
|
||||
orderBy: [
|
||||
{ seat: { coach: { number: 'asc' } } },
|
||||
{ seat: { seatNumber: 'asc' } },
|
||||
],
|
||||
select: {
|
||||
passengerName: true,
|
||||
passengerCategory: true,
|
||||
seatLabelSnapshot: true,
|
||||
fareMinor: true,
|
||||
displayFareMinor: true,
|
||||
displayCurrency: true,
|
||||
seat: {
|
||||
select: {
|
||||
seatNumber: true,
|
||||
coach: { select: { number: true, coachType: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
passenger: {
|
||||
select: { user: { select: { phone: true, fullName: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!b) return { total: 0, totalBalanceEtbMinor: 0, rows: [], notFound: true };
|
||||
|
||||
const pi = b.paymentIntent;
|
||||
const actualMinor = b.displayTotalMinor ?? b.totalMinor;
|
||||
const actualCurrency = (b.displayCurrency as string | null) ?? b.currency;
|
||||
const paidMinor = pi?.amountMinor ?? 0;
|
||||
const paidCurrency = pi?.currency ?? b.currency;
|
||||
|
||||
const owedEtb = b.totalMinor;
|
||||
const paidEtb = toEtbMinor(paidMinor, paidCurrency);
|
||||
const balanceMinor = owedEtb - paidEtb;
|
||||
const balanceCurrency = 'ETB';
|
||||
|
||||
const firstSeat = b.seats[0];
|
||||
const passengers = this.buildSeatPassengers(b.seats, actualCurrency);
|
||||
const row = {
|
||||
pnr: b.bookingRef,
|
||||
passengerName: firstSeat?.passengerName ?? b.passenger?.user?.fullName ?? '—',
|
||||
phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—',
|
||||
bookingDate: b.createdAt,
|
||||
origin: b.schedule.originStation,
|
||||
destination: b.schedule.destinationStation,
|
||||
departureAt: b.schedule.departureAt,
|
||||
seatType: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—',
|
||||
coachNumber: firstSeat?.seat?.coach?.number ?? null,
|
||||
actualMinor,
|
||||
actualCurrency,
|
||||
paidMinor,
|
||||
paidCurrency,
|
||||
balanceMinor,
|
||||
balanceCurrency,
|
||||
bookingStatus: b.status,
|
||||
paymentStatus: pi?.status ?? null,
|
||||
passengerCount: passengers.length,
|
||||
passengers,
|
||||
};
|
||||
|
||||
return {
|
||||
total: balanceMinor > 0 ? 1 : 0,
|
||||
totalBalanceEtbMinor: balanceMinor > 0 ? balanceMinor : 0,
|
||||
rows: [row],
|
||||
notFound: false,
|
||||
};
|
||||
}
|
||||
|
||||
private buildSeatPassengers(seats: any[], fallbackCurrency: string) {
|
||||
return seats.map(s => ({
|
||||
name: s.passengerName as string,
|
||||
category: s.passengerCategory as string,
|
||||
seatNumber: (s.seat?.seatNumber ?? null) as string | null,
|
||||
coachNumber: (s.seat?.coach?.number ?? null) as string | null,
|
||||
seatType: (s.seatLabelSnapshot ?? s.seat?.coach?.coachType?.name ?? null) as string | null,
|
||||
fareMinor: (s.displayFareMinor ?? s.fareMinor ?? null) as number | null,
|
||||
fareCurrency: ((s.displayCurrency as string | null) ?? fallbackCurrency),
|
||||
}));
|
||||
}
|
||||
|
||||
async getReport(reportId: string) {
|
||||
return this.prisma.operationalReport.findUnique({
|
||||
where: { id: reportId },
|
||||
|
||||
20
apps/edr-passenger-api/src/modules/storage/minio.config.ts
Normal file
20
apps/edr-passenger-api/src/modules/storage/minio.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
/**
|
||||
* Mirrors the freight API's MinIO config so both apps read the same env vars and
|
||||
* behave the same against the same object store. Kept as a copy rather than a
|
||||
* shared package because the two APIs share no runtime code today, and a config
|
||||
* package for six fields would be more coupling than it saves.
|
||||
*/
|
||||
export const minioConfig = registerAs('minio', () => ({
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'minio-dev.smart.aaca.gov.et',
|
||||
port: parseInt(process.env.MINIO_PORT || '443', 10),
|
||||
useSSL: process.env.MINIO_USE_SSL !== 'false',
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || '',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || '',
|
||||
bucket: process.env.MINIO_BUCKET || 'edr-dev',
|
||||
// Preset the region so presignedGetObject signs URLs locally. Without it the
|
||||
// minio client fires a live GetBucketLocation request on every sign, which
|
||||
// blocks (no timeout) when MinIO is slow and would hang every thread load.
|
||||
region: process.env.MINIO_REGION || 'us-east-1',
|
||||
}));
|
||||
90
apps/edr-passenger-api/src/modules/storage/minio.service.ts
Normal file
90
apps/edr-passenger-api/src/modules/storage/minio.service.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigType } from '@nestjs/config';
|
||||
import { Client } from 'minio';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { minioConfig } from './minio.config';
|
||||
|
||||
/**
|
||||
* Minimal object-storage client for the passenger API.
|
||||
*
|
||||
* A deliberate subset of the freight MinioService — only what chat attachments
|
||||
* need (put, sign, stream, key-from-url). Freight's extra surface (delete,
|
||||
* public URLs for unauthenticated links) is omitted rather than copied
|
||||
* speculatively.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MinioService {
|
||||
private readonly client: Client;
|
||||
private readonly logger = new Logger(MinioService.name);
|
||||
private readonly bucket: string;
|
||||
|
||||
constructor(
|
||||
@Inject(minioConfig.KEY)
|
||||
private readonly config: ConfigType<typeof minioConfig>,
|
||||
) {
|
||||
this.bucket = config.bucket;
|
||||
this.client = new Client({
|
||||
endPoint: config.endPoint,
|
||||
port: config.port,
|
||||
useSSL: config.useSSL,
|
||||
accessKey: config.accessKey,
|
||||
secretKey: config.secretKey,
|
||||
region: config.region,
|
||||
});
|
||||
}
|
||||
|
||||
async uploadFile(objectName: string, buffer: Buffer, contentType: string): Promise<string> {
|
||||
await this.client.putObject(this.bucket, objectName, buffer, buffer.length, {
|
||||
'Content-Type': contentType,
|
||||
});
|
||||
return this.getObjectUrl(objectName);
|
||||
}
|
||||
|
||||
/** Unsigned object URL — what gets persisted. Not browser-fetchable. */
|
||||
getObjectUrl(objectName: string): string {
|
||||
const protocol = this.config.useSSL ? 'https' : 'http';
|
||||
return `${protocol}://${this.config.endPoint}:${this.config.port}/${this.bucket}/${objectName}`;
|
||||
}
|
||||
|
||||
getObjectNameFromUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) throw new NotFoundException('File object path is empty');
|
||||
if (!/^https?:\/\//i.test(trimmed)) return trimmed.replace(/^\/+/, '');
|
||||
|
||||
const url = new URL(trimmed);
|
||||
// pathname percent-encodes the key (a space becomes "%20") but MinIO stores
|
||||
// the literal characters, so decode each segment or a file whose name had
|
||||
// spaces 404s with "specified key does not exist".
|
||||
const parts = url.pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => decodeURIComponent(segment));
|
||||
if (parts[0] === this.bucket) parts.shift();
|
||||
|
||||
const objectName = parts.join('/');
|
||||
if (!objectName) throw new NotFoundException('File object path is empty');
|
||||
return objectName;
|
||||
}
|
||||
|
||||
async getFileStream(objectName: string): Promise<Readable> {
|
||||
return this.client.getObject(this.bucket, objectName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived signed URL for inline preview.
|
||||
*
|
||||
* Unlike the freight twin this does NOT degrade to an unsigned public URL when
|
||||
* signing fails: a chat attachment is another passenger's file, and quietly
|
||||
* handing back a URL that only works if the bucket is world-readable trades a
|
||||
* visible error for a silent access-control surprise. Fail loudly instead.
|
||||
*/
|
||||
async getSignedUrl(objectName: string, expirySeconds: number): Promise<string> {
|
||||
try {
|
||||
return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to sign URL for ${objectName}: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
apps/edr-passenger-api/src/modules/storage/storage.module.ts
Normal file
12
apps/edr-passenger-api/src/modules/storage/storage.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { minioConfig } from './minio.config';
|
||||
import { MinioService } from './minio.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule.forFeature(minioConfig)],
|
||||
providers: [MinioService],
|
||||
exports: [MinioService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SUPPORT_ATTACHMENT_MAX_BYTES, SUPPORT_ATTACHMENT_MAX_PER_MESSAGE } from '@edr/types';
|
||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||
|
||||
/** Multipart field name carrying chat files. */
|
||||
export const SUPPORT_ATTACHMENT_FIELD = 'attachments';
|
||||
|
||||
/**
|
||||
* Multer-level caps for the chat send routes.
|
||||
*
|
||||
* These duplicate `SupportService.assertSendable` on purpose and don't replace
|
||||
* it: Multer stops reading the socket once a part exceeds `fileSize`, so an
|
||||
* oversized upload is cut off mid-stream rather than buffered into memory and
|
||||
* rejected afterwards. The service check produces the readable error.
|
||||
*/
|
||||
export const supportAttachmentMulterOptions: MulterOptions = {
|
||||
limits: {
|
||||
fileSize: SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
files: SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
},
|
||||
};
|
||||
52
apps/edr-passenger-api/src/modules/support/message-cursor.ts
Normal file
52
apps/edr-passenger-api/src/modules/support/message-cursor.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Keyset cursor for paging a thread backwards from newest.
|
||||
*
|
||||
* The sort key is the pair `(createdAt, id)`, not `createdAt` alone: two
|
||||
* messages can share a millisecond, and a cursor on a non-unique key either
|
||||
* re-serves or skips the tied rows depending which side of the boundary they
|
||||
* land on. The id breaks ties with a stable total order.
|
||||
*
|
||||
* Deliberately a twin of the freight API's `message-cursor.ts`, not a shared
|
||||
* import: the two APIs share no runtime package, and @edr/types is Nest-free by
|
||||
* design (this throws Nest exceptions). The wire format matches so a client can
|
||||
* treat both chats identically — keep them in step if either changes.
|
||||
*/
|
||||
export interface MessageCursor {
|
||||
createdAt: Date;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function encodeMessageCursor(cursor: MessageCursor): string {
|
||||
return Buffer.from(`${cursor.createdAt.toISOString()}|${cursor.id}`, 'utf8').toString(
|
||||
'base64url',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a client-supplied cursor. Rejects anything malformed rather than
|
||||
* silently falling back to "first page" — a corrupted cursor that degrades to
|
||||
* page 1 makes an infinite scroll loop forever over the same rows.
|
||||
*/
|
||||
export function decodeMessageCursor(raw: string): MessageCursor {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = Buffer.from(raw, 'base64url').toString('utf8');
|
||||
} catch {
|
||||
throw new BadRequestException('Malformed pagination cursor.');
|
||||
}
|
||||
|
||||
const separator = decoded.lastIndexOf('|');
|
||||
if (separator === -1) {
|
||||
throw new BadRequestException('Malformed pagination cursor.');
|
||||
}
|
||||
|
||||
const createdAt = new Date(decoded.slice(0, separator));
|
||||
const id = decoded.slice(separator + 1);
|
||||
if (Number.isNaN(createdAt.getTime()) || !id) {
|
||||
throw new BadRequestException('Malformed pagination cursor.');
|
||||
}
|
||||
|
||||
return { createdAt, id };
|
||||
}
|
||||
@@ -7,21 +7,33 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { Response } from 'express';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiConsumes } from '@nestjs/swagger';
|
||||
import { SUPPORT_ATTACHMENT_MAX_PER_MESSAGE } from '@edr/types';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SupportService } from './support.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import {
|
||||
SUPPORT_ATTACHMENT_FIELD,
|
||||
supportAttachmentMulterOptions,
|
||||
} from './attachment-upload.options';
|
||||
import {
|
||||
CreateConversationDto,
|
||||
CreateGuestConversationDto,
|
||||
DeviceIdBodyDto,
|
||||
DeviceSendMessageDto,
|
||||
DeviceThreadQueryDto,
|
||||
GuestIdBodyDto,
|
||||
GuestSendMessageDto,
|
||||
ListConversationsQueryDto,
|
||||
ListMessagesQueryDto,
|
||||
SendMessageDto,
|
||||
UpdateStatusDto,
|
||||
} from './support.dto';
|
||||
@@ -32,6 +44,35 @@ function userId(req: any): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multipart send routes accept `text` + `attachments` file parts; a plain-JSON
|
||||
* body still works (Multer passes non-multipart requests through untouched), so
|
||||
* text-only clients are unaffected.
|
||||
*/
|
||||
const attachmentsInterceptor = () =>
|
||||
UseInterceptors(
|
||||
FilesInterceptor(
|
||||
SUPPORT_ATTACHMENT_FIELD,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
supportAttachmentMulterOptions,
|
||||
),
|
||||
);
|
||||
|
||||
/** Swagger body schema for a send route: optional text + optional files. */
|
||||
const sendBodySchema = (extra: Record<string, unknown> = {}) => ({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...extra,
|
||||
text: { type: 'string' },
|
||||
attachments: {
|
||||
type: 'array',
|
||||
items: { type: 'string', format: 'binary' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ApiTags('Support')
|
||||
@Controller('support')
|
||||
export class SupportController {
|
||||
@@ -74,19 +115,37 @@ export class SupportController {
|
||||
@Get('conversations/:id/messages')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List messages in one of my conversations' })
|
||||
messages(@Req() req: any, @Param('id') id: string) {
|
||||
return this.service.getMessages(id, { iamUserId: userId(req) });
|
||||
@ApiOperation({
|
||||
summary: 'List messages in one of my conversations (newest page first)',
|
||||
description:
|
||||
'Keyset-paginated backwards from newest. Omit `before` for the newest ' +
|
||||
'page, then pass the previous `nextCursor`. Null means start of thread.',
|
||||
})
|
||||
messages(@Req() req: any, @Param('id') id: string, @Query() query: ListMessagesQueryDto) {
|
||||
return this.service.getMessages(id, query, { iamUserId: userId(req) });
|
||||
}
|
||||
|
||||
@Post('conversations/:id/messages')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@attachmentsInterceptor()
|
||||
@ApiConsumes('multipart/form-data', 'application/json')
|
||||
@ApiBody(sendBodySchema())
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Send a message as the customer' })
|
||||
send(@Req() req: any, @Param('id') id: string, @Body() body: SendMessageDto) {
|
||||
return this.service.sendMessage(id, 'USER', body.text, {
|
||||
iamUserId: userId(req),
|
||||
});
|
||||
send(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() body: SendMessageDto,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||
) {
|
||||
return this.service.sendMessage(
|
||||
id,
|
||||
'USER',
|
||||
body.text,
|
||||
{ iamUserId: userId(req) },
|
||||
attachments ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@Post('conversations/:id/read')
|
||||
@@ -111,16 +170,29 @@ export class SupportController {
|
||||
|
||||
@Get('device/thread')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: "Get the device's support thread + messages" })
|
||||
deviceThread(@Query('deviceId') deviceId: string) {
|
||||
return this.service.getDeviceThread(deviceId);
|
||||
@ApiOperation({
|
||||
summary: "Get the device's support thread + its newest page of messages",
|
||||
description:
|
||||
'`messages` is the newest page only, not the whole thread — page back ' +
|
||||
'with `nextCursor` via this same route.',
|
||||
})
|
||||
deviceThread(@Query() query: DeviceThreadQueryDto) {
|
||||
return this.service.getDeviceThread(query.deviceId, query);
|
||||
}
|
||||
|
||||
@Post('device/messages')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Send a message (creates the thread on first send)' })
|
||||
deviceSend(@Body() body: DeviceSendMessageDto) {
|
||||
return this.service.sendDeviceMessage(body.deviceId, body.text);
|
||||
@attachmentsInterceptor()
|
||||
@ApiConsumes('multipart/form-data', 'application/json')
|
||||
@ApiBody(sendBodySchema({ deviceId: { type: 'string' } }))
|
||||
@ApiOperation({
|
||||
summary: 'Send a message (creates the thread on first send)',
|
||||
})
|
||||
deviceSend(
|
||||
@Body() body: DeviceSendMessageDto,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||
) {
|
||||
return this.service.sendDeviceMessage(body.deviceId, body.text, attachments ?? []);
|
||||
}
|
||||
|
||||
@Post('device/read')
|
||||
@@ -137,6 +209,37 @@ export class SupportController {
|
||||
return this.service.unreadCount('USER', { guestId: deviceId });
|
||||
}
|
||||
|
||||
// ---- chat attachments --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Serves both audiences (portal device threads and the backoffice inbox) from
|
||||
* one path, because a new message is pushed to both over the socket in a
|
||||
* single payload — an identity-bearing URL would be wrong for one of them.
|
||||
*
|
||||
* Public for the same reason the device thread is: access to a passenger
|
||||
* support thread is already whoever-holds-the-id. See `streamAttachment` for
|
||||
* the full trade-off and the TODO to tighten it with the agent-route gating.
|
||||
*/
|
||||
@Get('attachments/:fileId')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Stream a support chat attachment' })
|
||||
async attachment(
|
||||
@Param('fileId') fileId: string,
|
||||
@Query('download') download: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { stream, mimeType, name } = await this.service.streamAttachment(fileId);
|
||||
const forceDownload = download === '1' || download === 'true';
|
||||
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`${forceDownload ? 'attachment' : 'inline'}; filename="${name}"`,
|
||||
);
|
||||
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
// ---- customer: guest (unauthenticated, multi-ticket) ------------------
|
||||
// No JwtGuard. Access is scoped by a client-generated `guestId` (the bearer
|
||||
// of access — anyone with it sees that thread; accepted MVP trade-off).
|
||||
@@ -150,28 +253,42 @@ export class SupportController {
|
||||
|
||||
@Get('guest/conversations')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'List a guest\'s conversations' })
|
||||
guestList(
|
||||
@Query('guestId') guestId: string,
|
||||
@Query() query: ListConversationsQueryDto,
|
||||
) {
|
||||
@ApiOperation({ summary: "List a guest's conversations" })
|
||||
guestList(@Query('guestId') guestId: string, @Query() query: ListConversationsQueryDto) {
|
||||
return this.service.listForCustomer({ guestId }, query);
|
||||
}
|
||||
|
||||
@Get('guest/conversations/:id/messages')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'List messages in a guest conversation' })
|
||||
guestMessages(@Param('id') id: string, @Query('guestId') guestId: string) {
|
||||
return this.service.getMessages(id, { guestId });
|
||||
@ApiOperation({
|
||||
summary: 'List messages in a guest conversation (newest page first)',
|
||||
})
|
||||
guestMessages(
|
||||
@Param('id') id: string,
|
||||
@Query('guestId') guestId: string,
|
||||
@Query() query: ListMessagesQueryDto,
|
||||
) {
|
||||
return this.service.getMessages(id, query, { guestId });
|
||||
}
|
||||
|
||||
@Post('guest/conversations/:id/messages')
|
||||
@IsPublic()
|
||||
@attachmentsInterceptor()
|
||||
@ApiConsumes('multipart/form-data', 'application/json')
|
||||
@ApiBody(sendBodySchema({ guestId: { type: 'string' } }))
|
||||
@ApiOperation({ summary: 'Send a message as a guest' })
|
||||
guestSend(@Param('id') id: string, @Body() body: GuestSendMessageDto) {
|
||||
return this.service.sendMessage(id, 'USER', body.text, {
|
||||
guestId: body.guestId,
|
||||
});
|
||||
guestSend(
|
||||
@Param('id') id: string,
|
||||
@Body() body: GuestSendMessageDto,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||
) {
|
||||
return this.service.sendMessage(
|
||||
id,
|
||||
'USER',
|
||||
body.text,
|
||||
{ guestId: body.guestId },
|
||||
attachments ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@Post('guest/conversations/:id/read')
|
||||
@@ -183,7 +300,7 @@ export class SupportController {
|
||||
|
||||
@Get('guest/unread-count')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Count a guest\'s unread conversations' })
|
||||
@ApiOperation({ summary: "Count a guest's unread conversations" })
|
||||
guestUnread(@Query('guestId') guestId: string) {
|
||||
return this.service.unreadCount('USER', { guestId });
|
||||
}
|
||||
@@ -202,17 +319,29 @@ export class SupportController {
|
||||
@Get('agent/conversations/:id/messages')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List messages in a conversation' })
|
||||
agentMessages(@Param('id') id: string) {
|
||||
return this.service.getMessages(id);
|
||||
@ApiOperation({
|
||||
summary: 'List messages in a conversation (newest page first)',
|
||||
description:
|
||||
'Keyset-paginated backwards from newest. Omit `before` for the newest ' +
|
||||
'page, then pass the previous `nextCursor`. Null means start of thread.',
|
||||
})
|
||||
agentMessages(@Param('id') id: string, @Query() query: ListMessagesQueryDto) {
|
||||
return this.service.getMessages(id, query);
|
||||
}
|
||||
|
||||
@Post('agent/conversations/:id/messages')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Reply as an agent' })
|
||||
agentSend(@Param('id') id: string, @Body() body: SendMessageDto) {
|
||||
return this.service.sendMessage(id, 'AGENT', body.text);
|
||||
@attachmentsInterceptor()
|
||||
@ApiConsumes('multipart/form-data', 'application/json')
|
||||
@ApiBody(sendBodySchema())
|
||||
@ApiOperation({ summary: 'Reply as an agent, optionally with attachments' })
|
||||
agentSend(
|
||||
@Param('id') id: string,
|
||||
@Body() body: SendMessageDto,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||
) {
|
||||
return this.service.sendMessage(id, 'AGENT', body.text, undefined, attachments ?? []);
|
||||
}
|
||||
|
||||
@Patch('agent/conversations/:id/status')
|
||||
|
||||
@@ -32,12 +32,19 @@ export class CreateConversationDto {
|
||||
initialMessage!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text is optional across the send DTOs because a message may be nothing but
|
||||
* attachments. "Neither text nor files" is rejected in the service rather than
|
||||
* here — the validator can't see the multipart file parts.
|
||||
*/
|
||||
export class SendMessageDto {
|
||||
@ApiProperty({ description: 'Message text.' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Message text. Optional only when attachments are present.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
text!: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export class CreateGuestConversationDto {
|
||||
@@ -79,11 +86,13 @@ export class GuestSendMessageDto {
|
||||
@Length(8, 120)
|
||||
guestId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Message text.' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Message text. Optional only when attachments are present.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
text!: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export class GuestIdBodyDto {
|
||||
@@ -99,11 +108,13 @@ export class DeviceSendMessageDto {
|
||||
@Length(8, 120)
|
||||
deviceId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Message text.' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Message text. Optional only when attachments are present.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
text!: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export class DeviceIdBodyDto {
|
||||
@@ -145,3 +156,38 @@ export class ListConversationsQueryDto {
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Default page size for a thread — roughly two screens of bubbles. */
|
||||
export const SUPPORT_MESSAGES_DEFAULT_LIMIT = 30;
|
||||
export const SUPPORT_MESSAGES_MAX_LIMIT = 100;
|
||||
|
||||
export class ListMessagesQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Opaque cursor from a previous response's `nextCursor`. Returns the page " +
|
||||
'of messages immediately OLDER than the cursor. Omit for the newest page.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
before?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 1,
|
||||
maximum: SUPPORT_MESSAGES_MAX_LIMIT,
|
||||
default: SUPPORT_MESSAGES_DEFAULT_LIMIT,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(SUPPORT_MESSAGES_MAX_LIMIT)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Query form of {@link ListMessagesQueryDto} for the device-scoped thread. */
|
||||
export class DeviceThreadQueryDto extends ListMessagesQueryDto {
|
||||
@ApiProperty({ description: 'Client device id (localStorage).' })
|
||||
@IsString()
|
||||
@Length(8, 120)
|
||||
deviceId!: string;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
|
||||
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { SupportController } from './support.controller';
|
||||
import { SupportService } from './support.service';
|
||||
import { SupportGateway } from './support.gateway';
|
||||
@@ -10,7 +11,8 @@ import { WsAuthService } from './ws-auth.service';
|
||||
@Module({
|
||||
// Session is served by the app's default TypeORM DataSource (IAM schema) —
|
||||
// used by WsAuthService to authenticate WebSocket handshakes.
|
||||
imports: [TypeOrmModule.forFeature([Session])],
|
||||
// StorageModule — MinioService for chat attachment bytes.
|
||||
imports: [TypeOrmModule.forFeature([Session]), StorageModule],
|
||||
controllers: [SupportController],
|
||||
providers: [SupportService, SupportGateway, WsAuthService],
|
||||
})
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Passenger as T } from '@edr/types';
|
||||
import {
|
||||
isSupportAttachmentAllowed,
|
||||
Passenger as T,
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
} from '@edr/types';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Readable } from 'stream';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { MinioService } from '../storage/minio.service';
|
||||
import { decodeMessageCursor, encodeMessageCursor } from './message-cursor';
|
||||
import { SUPPORT_MESSAGES_DEFAULT_LIMIT } from './support.dto';
|
||||
import { SupportGateway } from './support.gateway';
|
||||
|
||||
type Side = 'USER' | 'AGENT';
|
||||
type PrismaSender = 'USER' | 'BOT' | 'AGENT';
|
||||
type PrismaStatus = 'OPEN' | 'RESOLVED' | 'CLOSED';
|
||||
|
||||
/** Stand-in preview for a message that is nothing but files. */
|
||||
const ATTACHMENT_ONLY_PREVIEW = '📎';
|
||||
|
||||
interface MessagesQuery {
|
||||
before?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
type AttachmentRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type MessageRow = {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
sender: PrismaSender;
|
||||
text: string | null;
|
||||
createdAt: Date;
|
||||
attachments?: AttachmentRow[];
|
||||
};
|
||||
|
||||
/** Who the caller is on the customer side: an authed passenger or a guest. */
|
||||
export interface CustomerOwner {
|
||||
iamUserId?: string | null;
|
||||
@@ -50,6 +86,7 @@ export class SupportService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private gateway: SupportGateway,
|
||||
private minio: MinioService,
|
||||
) {}
|
||||
|
||||
// ---- FAQ (unchanged) ---------------------------------------------------
|
||||
@@ -115,29 +152,37 @@ export class SupportService {
|
||||
|
||||
// ---- customer: device-scoped single thread (portal) -------------------
|
||||
|
||||
/** The device's single conversation + its messages ({conversation:null} if none). */
|
||||
async getDeviceThread(deviceId: string): Promise<T.PassengerSupportThreadDto> {
|
||||
if (!deviceId) return { conversation: null, messages: [] };
|
||||
/**
|
||||
* The device's single conversation + its NEWEST page of messages
|
||||
* ({conversation:null} if none). Not the whole thread — the client pages back
|
||||
* with `nextCursor` exactly as the backoffice does.
|
||||
*/
|
||||
async getDeviceThread(
|
||||
deviceId: string,
|
||||
query: MessagesQuery = {},
|
||||
): Promise<T.PassengerSupportThreadDto> {
|
||||
const empty = { conversation: null, messages: [], nextCursor: null };
|
||||
if (!deviceId) return empty;
|
||||
const c = (await this.prisma.supportConversation.findFirst({
|
||||
where: { guestId: deviceId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})) as ConversationRow | null;
|
||||
if (!c) return { conversation: null, messages: [] };
|
||||
const rows = await this.prisma.supportMessage.findMany({
|
||||
where: { conversationId: c.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (!c) return empty;
|
||||
|
||||
const page = await this.listMessages(c.id, query);
|
||||
const unread = await this.computeUnread([c], 'USER');
|
||||
return {
|
||||
conversation: this.toConversationDto(c, unread.get(c.id) ?? 0),
|
||||
messages: rows.map((m) => this.toMessageDto(m)),
|
||||
messages: page.items,
|
||||
nextCursor: page.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
/** Append a message to the device's thread, creating it on first message. */
|
||||
async sendDeviceMessage(
|
||||
deviceId: string,
|
||||
text: string,
|
||||
text: string | undefined,
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<T.PassengerSupportMessageDto> {
|
||||
let c = (await this.prisma.supportConversation.findFirst({
|
||||
where: { guestId: deviceId },
|
||||
@@ -148,9 +193,7 @@ export class SupportService {
|
||||
data: { guestId: deviceId, subject: 'Support chat', status: 'OPEN' },
|
||||
})) as ConversationRow;
|
||||
}
|
||||
const updated = await this.appendMessage(c, 'USER', text);
|
||||
const last = updated.messages[updated.messages.length - 1];
|
||||
return this.toMessageDto(last);
|
||||
return this.appendMessage(c, 'USER', text, attachments);
|
||||
}
|
||||
|
||||
/** Mark the device's thread read (customer side). */
|
||||
@@ -186,9 +229,7 @@ export class SupportService {
|
||||
|
||||
// ---- agent (backoffice) ------------------------------------------------
|
||||
|
||||
async listForAgents(
|
||||
query: ListQuery,
|
||||
): Promise<T.PassengerSupportConversationListResult> {
|
||||
async listForAgents(query: ListQuery): Promise<T.PassengerSupportConversationListResult> {
|
||||
const where = this.listWhere(query);
|
||||
const rows = (await this.prisma.supportConversation.findMany({
|
||||
where,
|
||||
@@ -216,32 +257,29 @@ export class SupportService {
|
||||
|
||||
// ---- shared ------------------------------------------------------------
|
||||
|
||||
/** One page of a thread, newest first. See {@link listMessages}. */
|
||||
async getMessages(
|
||||
conversationId: string,
|
||||
query: MessagesQuery = {},
|
||||
asCustomer?: CustomerOwner,
|
||||
): Promise<T.PassengerSupportMessageDto[]> {
|
||||
): Promise<T.PassengerSupportMessageListResult> {
|
||||
const conversation = await this.requireConversation(conversationId);
|
||||
if (asCustomer) this.assertOwns(conversation, asCustomer);
|
||||
const rows = await this.prisma.supportMessage.findMany({
|
||||
where: { conversationId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return rows.map((m) => this.toMessageDto(m));
|
||||
return this.listMessages(conversationId, query);
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
conversationId: string,
|
||||
sender: Side,
|
||||
text: string,
|
||||
text: string | undefined,
|
||||
asCustomer?: CustomerOwner,
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<T.PassengerSupportMessageDto> {
|
||||
const conversation = await this.requireConversation(conversationId);
|
||||
if (sender === 'USER') {
|
||||
this.assertOwns(conversation, asCustomer ?? {});
|
||||
}
|
||||
const updated = await this.appendMessage(conversation, sender, text);
|
||||
const last = updated.messages[updated.messages.length - 1];
|
||||
return this.toMessageDto(last);
|
||||
return this.appendMessage(conversation, sender, text, attachments);
|
||||
}
|
||||
|
||||
async markRead(
|
||||
@@ -265,10 +303,7 @@ export class SupportService {
|
||||
return this.unreadCount('AGENT');
|
||||
}
|
||||
|
||||
async unreadCount(
|
||||
side: Side,
|
||||
owner?: CustomerOwner,
|
||||
): Promise<{ unreadCount: number }> {
|
||||
async unreadCount(side: Side, owner?: CustomerOwner): Promise<{ unreadCount: number }> {
|
||||
const rows = (await this.prisma.supportConversation.findMany({
|
||||
where: side === 'USER' ? this.ownerScope(owner ?? {}) : {},
|
||||
select: { id: true, userLastReadAt: true, agentLastReadAt: true },
|
||||
@@ -289,49 +324,197 @@ export class SupportService {
|
||||
conversation: ConversationRow,
|
||||
text: string,
|
||||
): Promise<T.PassengerSupportConversationDto> {
|
||||
const { conversation: updated } = await this.appendMessageRaw(
|
||||
conversation,
|
||||
'USER',
|
||||
text,
|
||||
);
|
||||
const { conversation: updated } = await this.appendMessageRaw(conversation, 'USER', text);
|
||||
return this.toConversationDto(updated, 0);
|
||||
}
|
||||
|
||||
private async appendMessage(
|
||||
conversation: ConversationRow,
|
||||
sender: PrismaSender,
|
||||
text: string,
|
||||
) {
|
||||
const { conversation: updated } = await this.appendMessageRaw(
|
||||
conversation,
|
||||
sender,
|
||||
text,
|
||||
);
|
||||
return updated as ConversationRow & { messages: any[] };
|
||||
text: string | undefined,
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<T.PassengerSupportMessageDto> {
|
||||
const { message } = await this.appendMessageRaw(conversation, sender, text, attachments);
|
||||
return message;
|
||||
}
|
||||
|
||||
/** Persist a message, bump the conversation's denormalized fields, emit live. */
|
||||
/**
|
||||
* One page of a thread, walking backwards from newest.
|
||||
*
|
||||
* Keyset, not offset: a message arriving while the reader is scrolled back
|
||||
* would shift every offset by one and duplicate/skip rows across pages. Rides
|
||||
* the (conversationId, createdAt) index; the id is a tiebreak for messages
|
||||
* sharing a millisecond.
|
||||
*/
|
||||
private async listMessages(
|
||||
conversationId: string,
|
||||
query: MessagesQuery,
|
||||
): Promise<T.PassengerSupportMessageListResult> {
|
||||
const limit = query.limit ?? SUPPORT_MESSAGES_DEFAULT_LIMIT;
|
||||
const before = query.before ? decodeMessageCursor(query.before) : undefined;
|
||||
|
||||
const rows = (await this.prisma.supportMessage.findMany({
|
||||
where: {
|
||||
conversationId,
|
||||
...(before
|
||||
? {
|
||||
// Strictly older than the cursor in (createdAt, id) order.
|
||||
OR: [
|
||||
{ createdAt: { lt: before.createdAt } },
|
||||
{
|
||||
createdAt: before.createdAt,
|
||||
id: { lt: before.id },
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
// One more than asked, to tell "there's another page" from "this page was
|
||||
// simply full" without a second COUNT.
|
||||
take: limit + 1,
|
||||
include: { attachments: { orderBy: { createdAt: 'asc' } } },
|
||||
})) as MessageRow[];
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
const oldest = page[page.length - 1];
|
||||
const nextCursor =
|
||||
hasMore && oldest
|
||||
? encodeMessageCursor({ createdAt: oldest.createdAt, id: oldest.id })
|
||||
: null;
|
||||
|
||||
// Flip to oldest-first so a page prepends as one block.
|
||||
const items = await Promise.all([...page].reverse().map((m) => this.toMessageDto(m)));
|
||||
return { items, nextCursor };
|
||||
}
|
||||
|
||||
/** Persist a message (+ attachments), bump denormalized fields, emit live. */
|
||||
private async appendMessageRaw(
|
||||
conversation: ConversationRow,
|
||||
sender: PrismaSender,
|
||||
text: string,
|
||||
): Promise<{ conversation: ConversationRow & { messages: any[] }; message: any }> {
|
||||
text: string | undefined,
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<{
|
||||
conversation: ConversationRow;
|
||||
message: T.PassengerSupportMessageDto;
|
||||
}> {
|
||||
const trimmed = (text ?? '').trim();
|
||||
this.assertSendable(trimmed, attachments);
|
||||
|
||||
const message = await this.prisma.supportMessage.create({
|
||||
data: { conversationId: conversation.id, sender, text },
|
||||
// NULL, not "", so "no text" is representable rather than inferred. The
|
||||
// DTO flattens it back to "" for rendering.
|
||||
data: { conversationId: conversation.id, sender, text: trimmed || null },
|
||||
});
|
||||
|
||||
// The row has to exist before the files, since each is stored against
|
||||
// `messageId`. That leaves a window: if an upload fails here, the message is
|
||||
// already committed. Undo it rather than leave the thread with a
|
||||
// permanently blank bubble — there is no delete flow, so an orphan would be
|
||||
// unremovable, and an attachment-only message that lost its files has no
|
||||
// content at all.
|
||||
let stored: AttachmentRow[];
|
||||
try {
|
||||
stored = await this.storeAttachments(message.id, attachments);
|
||||
} catch (error) {
|
||||
await this.prisma.supportMessage.delete({ where: { id: message.id } });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const updated = (await this.prisma.supportConversation.update({
|
||||
where: { id: conversation.id },
|
||||
data: {
|
||||
lastMessageAt: message.createdAt,
|
||||
lastMessagePreview: text.slice(0, 280),
|
||||
lastMessagePreview: this.buildPreview(trimmed, stored),
|
||||
lastMessageSender: sender,
|
||||
},
|
||||
include: { messages: { orderBy: { createdAt: 'asc' } } },
|
||||
})) as ConversationRow & { messages: any[] };
|
||||
// Deliberately NOT `include: { messages: ... }` — that loaded every
|
||||
// message in the thread on every send just to read back the one we had in
|
||||
// hand.
|
||||
})) as ConversationRow;
|
||||
|
||||
const messageDto = await this.toMessageDto({
|
||||
...(message as MessageRow),
|
||||
attachments: stored,
|
||||
});
|
||||
const dto = this.toConversationDto(updated, 0);
|
||||
this.gateway.emitMessage(this.ownerRoom(updated), dto, this.toMessageDto(message));
|
||||
return { conversation: updated, message };
|
||||
this.gateway.emitMessage(this.ownerRoom(updated), dto, messageDto);
|
||||
return { conversation: updated, message: messageDto };
|
||||
}
|
||||
|
||||
/**
|
||||
* Push bytes to MinIO, then record them. Object keys are namespaced by message
|
||||
* id and the stored name is sanitized so the key survives the round-trip
|
||||
* through its own URL (spaces/unicode would otherwise percent-encode and no
|
||||
* longer match the key).
|
||||
*/
|
||||
private async storeAttachments(
|
||||
messageId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<AttachmentRow[]> {
|
||||
return Promise.all(
|
||||
files.map(async (file) => {
|
||||
const safeName = file.originalname
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\w.\-]+/g, '_')
|
||||
.replace(/_{2,}/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
// The random segment is load-bearing: `Date.now()` is NOT unique across
|
||||
// this batch, since every callback runs to its first await in the same
|
||||
// tick and reads the same millisecond. Two files sharing a name — e.g.
|
||||
// two pasted screenshots, which browsers both call "image.png" — would
|
||||
// otherwise build the same key and silently overwrite each other.
|
||||
const objectName = `support_message/${messageId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
|
||||
const url = await this.minio.uploadFile(objectName, file.buffer, file.mimetype);
|
||||
return this.prisma.supportAttachment.create({
|
||||
data: {
|
||||
messageId,
|
||||
name: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
url,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat upload rules — kept in step with the freight side via the shared
|
||||
* SUPPORT_ATTACHMENT_* constants. Notably excludes SVG: it's executable markup
|
||||
* and this is a file one user pushes at another.
|
||||
*/
|
||||
private assertSendable(text: string, attachments: Express.Multer.File[]): void {
|
||||
if (!text && attachments.length === 0) {
|
||||
throw new BadRequestException('A message needs text or at least one attachment.');
|
||||
}
|
||||
if (attachments.length > SUPPORT_ATTACHMENT_MAX_PER_MESSAGE) {
|
||||
throw new BadRequestException(
|
||||
`At most ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`,
|
||||
);
|
||||
}
|
||||
for (const file of attachments) {
|
||||
if (!isSupportAttachmentAllowed(file.mimetype)) {
|
||||
throw new BadRequestException(`Unsupported attachment type: ${file.mimetype}`);
|
||||
}
|
||||
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
|
||||
throw new BadRequestException(
|
||||
`"${file.originalname}" exceeds the ${
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)
|
||||
}MB attachment limit.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Inbox preview line — falls back to filenames when there's no text. */
|
||||
private buildPreview(text: string, attachments: AttachmentRow[]): string {
|
||||
if (text) return text.slice(0, 280);
|
||||
if (attachments.length === 1) {
|
||||
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments[0].name}`.slice(0, 280);
|
||||
}
|
||||
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments.length} files`;
|
||||
}
|
||||
|
||||
private async buildListResult(
|
||||
@@ -340,9 +523,7 @@ export class SupportService {
|
||||
side: Side,
|
||||
): Promise<T.PassengerSupportConversationListResult> {
|
||||
const unreadMap = await this.computeUnread(rows, side);
|
||||
const items = rows.map((r) =>
|
||||
this.toConversationDto(r, unreadMap.get(r.id) ?? 0),
|
||||
);
|
||||
const items = rows.map((r) => this.toConversationDto(r, unreadMap.get(r.id) ?? 0));
|
||||
let unreadCount = 0;
|
||||
for (const n of unreadMap.values()) if (n > 0) unreadCount++;
|
||||
return { items, count, unreadCount };
|
||||
@@ -365,10 +546,7 @@ export class SupportService {
|
||||
select: { conversationId: true, createdAt: true },
|
||||
});
|
||||
const cursorById = new Map(
|
||||
rows.map((r) => [
|
||||
r.id,
|
||||
side === 'USER' ? r.userLastReadAt : r.agentLastReadAt,
|
||||
]),
|
||||
rows.map((r) => [r.id, side === 'USER' ? r.userLastReadAt : r.agentLastReadAt]),
|
||||
);
|
||||
for (const m of msgs) {
|
||||
const cursor = cursorById.get(m.conversationId) ?? null;
|
||||
@@ -448,27 +626,75 @@ export class SupportService {
|
||||
};
|
||||
}
|
||||
|
||||
private toMessageDto(m: {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
sender: PrismaSender;
|
||||
text: string;
|
||||
createdAt: Date;
|
||||
}): T.PassengerSupportMessageDto {
|
||||
private async toMessageDto(m: MessageRow): Promise<T.PassengerSupportMessageDto> {
|
||||
return {
|
||||
id: m.id,
|
||||
conversationId: m.conversationId,
|
||||
sender: this.toDtoSender(m.sender) ?? T.PassengerSupportSender.AGENT,
|
||||
text: m.text,
|
||||
text: m.text ?? '',
|
||||
attachments: (m.attachments ?? []).map((a) => this.toAttachmentDto(a)),
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the client fetches the bytes: this API's own stream route, NOT a
|
||||
* presigned MinIO URL. Presigned object URLs are not reachable from the
|
||||
* browser in this deployment, which is why every working file in the platform
|
||||
* streams through the API instead.
|
||||
*
|
||||
* The path is audience-independent on purpose. A new message is pushed over
|
||||
* the socket to the device room *and* the backoffice room in one payload, so a
|
||||
* URL that embedded the caller's identity (a `?deviceId=`, say) would be wrong
|
||||
* for one of the two recipients.
|
||||
*
|
||||
* The client can't use this path as an `<img src>` either — the agent side's
|
||||
* guard only reads a bearer header, which an image request can't send — so the
|
||||
* web apps fetch it through their authenticated client and render a blob.
|
||||
*/
|
||||
private toAttachmentDto(a: AttachmentRow): T.PassengerSupportAttachmentDto {
|
||||
return {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
url: `/support/attachments/${a.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes for a chat attachment.
|
||||
*
|
||||
* Deliberately unscoped, and this is a trade-off worth naming: passenger
|
||||
* support threads are already reachable by whoever holds the device/guest id
|
||||
* (see the device routes — "anyone with the device id can see that thread",
|
||||
* the accepted MVP posture), and the agent routes admit any authenticated
|
||||
* caller pending real staff gating. Scoping this endpoint tighter than the
|
||||
* thread it belongs to would buy nothing, so it matches that posture: the
|
||||
* attachment UUID is the capability.
|
||||
*
|
||||
* TODO: tighten alongside the agent-route staff permission — at that point
|
||||
* both the thread and its attachments should be gated the same way.
|
||||
*/
|
||||
async streamAttachment(
|
||||
fileId: string,
|
||||
): Promise<{ stream: Readable; mimeType: string; name: string }> {
|
||||
const attachment = await this.prisma.supportAttachment.findUnique({
|
||||
where: { id: fileId },
|
||||
});
|
||||
if (!attachment) throw new NotFoundException('Attachment not found');
|
||||
|
||||
const objectName = this.minio.getObjectNameFromUrl(attachment.url);
|
||||
return {
|
||||
stream: await this.minio.getFileStream(objectName),
|
||||
mimeType: attachment.mimeType,
|
||||
name: attachment.name,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy BOT messages are surfaced as AGENT to the UI. */
|
||||
private toDtoSender(s: PrismaSender | null): T.PassengerSupportSender | null {
|
||||
if (!s) return null;
|
||||
return s === 'USER'
|
||||
? T.PassengerSupportSender.USER
|
||||
: T.PassengerSupportSender.AGENT;
|
||||
return s === 'USER' ? T.PassengerSupportSender.USER : T.PassengerSupportSender.AGENT;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user