src/client-portal/client-portal.service.ts
Client Portal service.
Lets dispatchers generate secure magic-link URLs and API keys per client, and lets the clients themselves submit orders without logging in.
Three submission channels land here:
All submissions:
source column (PORTAL_FORM | PORTAL_UPLOAD | API)
Methods |
|
constructor(prisma: PrismaService, ingestion: IngestionService)
|
|||||||||
|
Parameters :
|
| Async createToken | ||||||||||||
createToken(organizationId: string, createdById: string, dto: CreatePortalTokenDto)
|
||||||||||||
|
Parameters :
Returns :
unknown
|
| Async listTokens | ||||||
listTokens(clientId: string)
|
||||||
|
Parameters :
Returns :
unknown
|
| Async resolveToken | ||||||
resolveToken(token: string)
|
||||||
|
Parameters :
Returns :
unknown
|
| Async revokeToken | ||||||
revokeToken(id: string)
|
||||||
|
Parameters :
Returns :
unknown
|
| Async submitFileByToken | |||||||||||||||
submitFileByToken(token: string, file: Express.Multer.File, ip: string, userAgent?: string)
|
|||||||||||||||
|
Parameters :
Returns :
unknown
|
| Async submitOrderByApiKey | |||||||||||||||
submitOrderByApiKey(apiKey: string, dto: SubmitPortalOrderDto, ip: string, userAgent?: string)
|
|||||||||||||||
|
Parameters :
Returns :
unknown
|
| Async submitOrderByToken | |||||||||||||||
submitOrderByToken(token: string, dto: SubmitPortalOrderDto, ip: string, userAgent?: string)
|
|||||||||||||||
|
Parameters :
Returns :
unknown
|
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { randomBytes } from 'crypto';
import { PrismaClient } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { tenantStorage } from '../prisma/tenant-context';
import { IngestionService } from '../orders/ingestion/ingestion.service';
import { CreatePortalTokenDto, SubmitPortalOrderDto } from './dto/create-portal-token.dto';
/**
* Client Portal service.
*
* Lets dispatchers generate secure magic-link URLs and API keys per client,
* and lets the clients themselves submit orders without logging in.
*
* Three submission channels land here:
* 1. POST /client-portal/submit/:token — web form (public)
* 2. POST /client-portal/submit/:token/file — drag-drop Excel/CSV (public)
* 3. POST /client-portal/api/orders — API key in X-Client-Key header
*
* All submissions:
* - Create orders in PENDING_VALIDATION status
* - Stamp the orders with a `source` column (PORTAL_FORM | PORTAL_UPLOAD | API)
* - Create an INFO alert so dispatchers see new portal orders in real time
* - Get logged with IP + user-agent + client for audit
*/
@Injectable()
export class ClientPortalService {
private readonly logger = new Logger(ClientPortalService.name);
// Raw Prisma client for PUBLIC endpoints that run WITHOUT tenant context.
// The token itself is the secret — anyone who presents a valid token is
// authorized for the single client it's scoped to.
private readonly rawPrisma = new PrismaClient();
constructor(
private readonly prisma: PrismaService,
private readonly ingestion: IngestionService,
) {}
// ── Generate a new portal token + API key ─────────────────────────────
async createToken(organizationId: string, createdById: string, dto: CreatePortalTokenDto) {
const client = await this.prisma.client.findFirst({ where: { id: dto.clientId } });
if (!client) throw new NotFoundException('Client not found');
const token = this.generateToken();
const apiKey = `fck_live_${this.generateToken(24)}`;
return this.prisma.clientPortalToken.create({
data: {
organizationId,
clientId: dto.clientId,
token,
apiKey,
label: dto.label,
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
maxUses: dto.maxUses,
createdById,
},
include: { client: { select: { id: true, name: true, code: true } } },
});
}
async listTokens(clientId: string) {
return this.prisma.clientPortalToken.findMany({
where: { clientId },
orderBy: { createdAt: 'desc' },
});
}
async revokeToken(id: string) {
const token = await this.prisma.clientPortalToken.findFirst({ where: { id } });
if (!token) throw new NotFoundException('Token not found');
return this.prisma.clientPortalToken.update({ where: { id }, data: { isActive: false } });
}
// ── PUBLIC: resolve a token → returns client info to pre-fill the form ─
async resolveToken(token: string) {
const record = await this.rawPrisma.clientPortalToken.findUnique({ where: { token } });
this.validateTokenOrThrow(record);
// Fetch client + org inside a tenant-scoped tx so RLS allows it
const { client, organization } = await this.rawPrisma.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_org_id = '${record!.organizationId}'`);
const c = await tx.client.findUnique({
where: { id: record!.clientId },
select: { id: true, name: true, code: true, city: true, region: true, organizationId: true },
});
const o = await tx.organization.findUnique({
where: { id: record!.organizationId },
select: { id: true, name: true, subdomain: true, logoUrl: true },
});
return { client: c, organization: o };
});
return {
client,
organization,
label: record!.label,
currentUses: record!.currentUses,
maxUses: record!.maxUses,
expiresAt: record!.expiresAt,
};
}
// ── PUBLIC: submit a single order via portal link (form) ───────────────
async submitOrderByToken(token: string, dto: SubmitPortalOrderDto, ip: string, userAgent?: string) {
// Honeypot
if (dto.website && dto.website.trim() !== '') {
this.logger.warn(`Honeypot triggered on portal submission from IP ${ip}`);
throw new ForbiddenException('Submission rejected');
}
const record = await this.rawPrisma.clientPortalToken.findUnique({ where: { token } });
this.validateTokenOrThrow(record);
return this.createOrderForClient(record!.organizationId, record!.clientId, dto, {
portalTokenId: record!.id,
ip,
userAgent,
source: 'PORTAL_FORM',
});
}
// ── PUBLIC: submit an Excel/CSV file via portal link ───────────────────
// This is the REAL file upload handler — uses the ingestion pipeline so
// 100 rows → 100 orders, not 1 order with base64 stuffed in the notes.
//
// Since the ingestion service uses the tenant-aware PrismaService proxy
// (which reads tenant context from AsyncLocalStorage), we must wrap the
// call in a $transaction that sets `app.current_org_id` AND stores the
// tx client in the AsyncLocalStorage scope. Without this, RLS on the
// orders/ingestions tables rejects every write.
async submitFileByToken(
token: string,
file: Express.Multer.File,
ip: string,
userAgent?: string,
) {
if (!file || !file.buffer) {
throw new BadRequestException('No file received');
}
const record = await this.rawPrisma.clientPortalToken.findUnique({ where: { token } });
this.validateTokenOrThrow(record);
const orgId = record!.organizationId;
const clientId = record!.clientId;
this.logger.log(
`Portal file upload: ${file.originalname} (${file.size} bytes) from client ${clientId} IP ${ip}`,
);
// Run the entire ingestion pipeline inside a tenant-scoped transaction
// so the proxy routes all nested queries to the RLS-aware tx client.
const result = await (this.prisma as any).$transaction(
async (tx: any) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_org_id = '${orgId}'`);
// Wrap the ingestion call so the proxy sees the tx context
return tenantStorage.run(
{ organizationId: orgId, tx },
async () => {
return this.ingestion.processPortalFileUpload(file, clientId, orgId);
},
);
},
{ timeout: 120_000, maxWait: 10_000 },
);
// Post-processing: set status to PENDING_VALIDATION + bump token usage
await (this.prisma as any).$transaction(async (tx: any) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_org_id = '${orgId}'`);
await tx.order.updateMany({
where: { ingestionId: result.ingestionId },
data: { status: 'PENDING_VALIDATION' },
});
});
// Token counter (not under RLS)
await this.rawPrisma.clientPortalToken.update({
where: { id: record!.id },
data: {
currentUses: { increment: result.orderCount },
lastUsedAt: new Date(),
lastUsedIp: ip,
},
});
// Dispatcher alert
await this.createPortalAlert({
organizationId: orgId,
clientId,
orderCount: result.orderCount,
source: 'PORTAL_UPLOAD',
fileName: file.originalname,
ip,
});
this.logger.log(
`Portal upload complete: ${result.orderCount} orders created from ${file.originalname} for client ${clientId}`,
);
return {
success: true,
orderCount: result.orderCount,
orders: result.orders,
message: `${result.orderCount} order${result.orderCount === 1 ? '' : 's'} imported successfully`,
};
}
// ── PUBLIC: submit via API key ─────────────────────────────────────────
async submitOrderByApiKey(
apiKey: string,
dto: SubmitPortalOrderDto,
ip: string,
userAgent?: string,
) {
const record = await this.rawPrisma.clientPortalToken.findUnique({ where: { apiKey } });
if (!record) throw new NotFoundException('Invalid API key');
if (!record.isActive) throw new ForbiddenException('API key has been revoked');
if (record.expiresAt && record.expiresAt < new Date()) {
throw new ForbiddenException('API key has expired');
}
return this.createOrderForClient(record.organizationId, record.clientId, dto, {
portalTokenId: record.id,
ip,
userAgent,
source: 'API',
});
}
// ── Core order creation (shared by portal-link + api-key paths) ────────
private async createOrderForClient(
organizationId: string,
clientId: string,
dto: SubmitPortalOrderDto,
meta: {
portalTokenId: string;
ip: string;
userAgent?: string;
source: 'PORTAL_FORM' | 'API';
},
) {
if (!/^[0-9a-f-]{36}$/i.test(organizationId)) {
throw new BadRequestException('Invalid organization context');
}
const validPriorities = ['LOW', 'NORMAL', 'HIGH', 'CRITICAL'];
const priority = validPriorities.includes((dto.priority || '').toUpperCase())
? ((dto.priority || '').toUpperCase() as any)
: 'NORMAL';
const sourceLabel =
meta.source === 'PORTAL_FORM' ? 'Client Portal (form)' : 'Client API';
const result = await this.rawPrisma.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_org_id = '${organizationId}'`);
const year = new Date().getFullYear();
const count = await tx.order.count({ where: { organizationId } });
const orderNumber = `ORD-${year}-${String(count + 1).padStart(4, '0')}`;
// Compute missing fields so the order enters the same review
// workflow as Excel-uploaded orders (waive tab, Send to Tasks)
const missingFields: string[] = [];
if (!dto.originAddress) missingFields.push('originAddress');
if (!dto.destAddress) missingFields.push('destAddress');
if (!dto.commodity) missingFields.push('commodity');
if (!dto.weight && !dto.pieces && !dto.pallets) missingFields.push('weight/pieces');
if (!dto.deliveryDate) missingFields.push('deliveryDate');
const order = await tx.order.create({
data: {
organizationId,
clientId,
orderNumber,
referenceNumber: dto.referenceNumber,
status: 'PENDING_VALIDATION',
jobStatus: missingFields.length > 0 ? 'INCOMPLETE' : 'IMPORTED',
priority,
originAddress: dto.originAddress,
originName: dto.originName,
destAddress: dto.destAddress,
destName: dto.destName,
commodity: dto.commodity,
weight: dto.weight ? Number(dto.weight) : null,
pieces: dto.pieces ? Number(dto.pieces) : null,
pallets: dto.pallets ? Number(dto.pallets) : null,
pickupDate: dto.pickupDate ? new Date(dto.pickupDate) : null,
deliveryDate: dto.deliveryDate ? new Date(dto.deliveryDate) : null,
specialInstructions: dto.specialInstructions,
addedByName: sourceLabel,
source: meta.source as any,
missingFields: missingFields.length > 0 ? missingFields : [],
},
include: { client: { select: { id: true, name: true, code: true } } },
});
await tx.clientPortalToken.update({
where: { id: meta.portalTokenId },
data: {
currentUses: { increment: 1 },
lastUsedAt: new Date(),
lastUsedIp: meta.ip,
},
});
return order;
});
// Create dispatcher alert (outside the tx — non-blocking feel)
await this.createPortalAlert({
organizationId,
clientId,
orderCount: 1,
source: meta.source,
orderNumber: result.orderNumber,
clientName: result.client?.name,
ip: meta.ip,
});
this.logger.log(
`Portal order created: ${result.orderNumber} for client ${clientId} via ${meta.source} from ${meta.ip}`,
);
return result;
}
// ── Helpers ───────────────────────────────────────────────────────────
private validateTokenOrThrow(record: any) {
if (!record) throw new NotFoundException('Invalid or expired link');
if (!record.isActive) throw new ForbiddenException('This link has been revoked');
if (record.expiresAt && record.expiresAt < new Date()) {
throw new ForbiddenException('This link has expired');
}
if (record.maxUses && record.currentUses >= record.maxUses) {
throw new ForbiddenException('This link has reached its submission limit');
}
}
/**
* Create a dispatcher-visible alert for every portal / API / upload
* submission. Dispatchers see these in real time on the /alerts page.
*/
private async createPortalAlert(params: {
organizationId: string;
clientId: string;
orderCount: number;
source: 'PORTAL_FORM' | 'PORTAL_UPLOAD' | 'API';
orderNumber?: string;
clientName?: string;
fileName?: string;
ip: string;
}) {
try {
// Resolve client name if not provided
let clientName = params.clientName;
if (!clientName) {
await this.rawPrisma.$transaction(async (tx) => {
await tx.$executeRawUnsafe(
`SET LOCAL app.current_org_id = '${params.organizationId}'`,
);
const c = await tx.client.findUnique({
where: { id: params.clientId },
select: { name: true },
});
clientName = c?.name || 'Unknown client';
});
}
const sourceLabel =
params.source === 'PORTAL_UPLOAD'
? 'Excel/CSV upload'
: params.source === 'PORTAL_FORM'
? 'Portal form'
: 'API key';
const title =
params.orderCount === 1
? `📥 New order from ${clientName}`
: `📥 ${params.orderCount} new orders from ${clientName}`;
const messageParts = [
`Received via ${sourceLabel} from IP ${params.ip}.`,
];
if (params.orderNumber) messageParts.push(`Order: ${params.orderNumber}`);
if (params.fileName) messageParts.push(`File: ${params.fileName}`);
messageParts.push('');
messageParts.push(`Review in /orders and assign a vehicle when ready.`);
await this.rawPrisma.$transaction(async (tx) => {
await tx.$executeRawUnsafe(
`SET LOCAL app.current_org_id = '${params.organizationId}'`,
);
await tx.alert.create({
data: {
organizationId: params.organizationId,
severity: 'INFO',
status: 'ACTIVE',
title,
message: messageParts.join('\n'),
entityType: 'ORDER',
entityId: params.orderNumber || null,
},
});
});
} catch (err: any) {
// Non-blocking — don't fail the order submission if the alert fails
this.logger.error(`Failed to create portal alert: ${err.message}`);
}
}
private generateToken(bytes = 32): string {
return randomBytes(bytes).toString('base64url');
}
}