File

src/client-portal/client-portal.service.ts

Description

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

Index

Methods

Constructor

constructor(prisma: PrismaService, ingestion: IngestionService)
Parameters :
Name Type Optional
prisma PrismaService No
ingestion IngestionService No

Methods

Async createToken
createToken(organizationId: string, createdById: string, dto: CreatePortalTokenDto)
Parameters :
Name Type Optional
organizationId string No
createdById string No
dto CreatePortalTokenDto No
Returns : unknown
Async listTokens
listTokens(clientId: string)
Parameters :
Name Type Optional
clientId string No
Returns : unknown
Async resolveToken
resolveToken(token: string)
Parameters :
Name Type Optional
token string No
Returns : unknown
Async revokeToken
revokeToken(id: string)
Parameters :
Name Type Optional
id string No
Returns : unknown
Async submitFileByToken
submitFileByToken(token: string, file: Express.Multer.File, ip: string, userAgent?: string)
Parameters :
Name Type Optional
token string No
file Express.Multer.File No
ip string No
userAgent string Yes
Returns : unknown
Async submitOrderByApiKey
submitOrderByApiKey(apiKey: string, dto: SubmitPortalOrderDto, ip: string, userAgent?: string)
Parameters :
Name Type Optional
apiKey string No
dto SubmitPortalOrderDto No
ip string No
userAgent string Yes
Returns : unknown
Async submitOrderByToken
submitOrderByToken(token: string, dto: SubmitPortalOrderDto, ip: string, userAgent?: string)
Parameters :
Name Type Optional
token string No
dto SubmitPortalOrderDto No
ip string No
userAgent string Yes
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');
  }
}

results matching ""

    No results matching ""