import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AppLogIngestService } from './app-log-ingest.service'; import { JobsIngestService } from './jobs-ingest.service'; import { LogIngestService } from './log-ingest.service'; import { MqttClientService } from './mqtt-client.service'; import { PointsIngestService } from './points-ingest.service'; @Injectable() export class IngestRouterService implements OnModuleInit { private readonly logger = new Logger(IngestRouterService.name); constructor( private readonly prisma: PrismaService, private readonly mqttClient: MqttClientService, private readonly pointsIngest: PointsIngestService, private readonly jobsIngest: JobsIngestService, private readonly logIngest: LogIngestService, private readonly appLogIngest: AppLogIngestService, ) {} onModuleInit() { this.mqttClient.onMessage((topic, payload) => { this.route(topic, payload.toString()).catch((err) => this.logger.error(`Failed to process ${topic}: ${err.message}`), ); }); } private async route(topic: string, payload: string) { const segments = topic.split('/'); const root = segments[0]; // App point payloads contain personal/location data. Preserve routing evidence without // creating an unmanaged second copy of the telemetry in device_events (S2 security H3). const auditPayload = root === 'ul' && segments[2] === 'app' && segments.slice(4).join('/') === 'log/points' ? JSON.stringify({ redacted: true, bytes: Buffer.byteLength(payload, 'utf8'), }) : payload; await this.prisma.deviceEvent.create({ data: { topic, payload: auditPayload }, }); // App / device MQTT namespace: ul/{orgId}/{clientClass}/{clientId}/{subtopic...} // (SRS §3.4.2). Sprint 2 handles the durable app-log path; other clientClasses and // subtopics are represented but not yet ingested here. if (root === 'ul') { return this.routeUl(segments, payload); } const [, idSegment, ...rest] = segments; const subtopic = rest.join('/'); if (root !== 'devices' || !idSegment || subtopic === 'jobs/ack') { return; // not device traffic, or our own ack echoed back } // devices//log identifies the locator by serial in the topic // itself (job in the payload supplies org) — no publisher device lookup. if (subtopic === 'log') { await this.logIngest.handle(idSegment, payload); return; } const device = await this.prisma.device.findUnique({ where: { mqttUsername: idSegment }, }); if (!device) { this.logger.warn( `Message from unregistered device username "${idSegment}" (raw-logged only)`, ); return; } if (!device.isActive) { this.logger.warn( `Message from deactivated device "${idSegment}" ignored`, ); return; } this.prisma.device .update({ where: { id: device.id }, data: { lastSeenAt: new Date() } }) .catch(() => undefined); switch (subtopic) { case 'points': await this.pointsIngest.handle(device, payload); break; case 'jobs': await this.jobsIngest.handle(device, payload); break; case 'status': break; // lastSeenAt already stamped above default: this.logger.debug(`Unhandled subtopic "${subtopic}" from ${idSegment}`); } } // ul/{orgId}/{clientClass}/{clientId}/{subtopic...} — SRS §3.4.2 topic scheme. // The namespace itself carries tenant (orgId) + publisher (clientId) identity; the // broker ACL confines a publisher to its own namespace, and AppLogIngest re-checks // tenant scope on the payload's job as a backstop. Only the durable app-log path is // ingested this sprint. private async routeUl(segments: string[], payload: string) { const [, orgId, clientClass, clientId, ...rest] = segments; const subtopic = rest.join('/'); if (!orgId || !clientClass || !clientId) { this.logger.warn(`Malformed ul topic "${segments.join('/')}" — ignored`); return; } // Our own cloud→publisher acks are echoed back to us on the same subscription; skip. if (subtopic === 'ack') { return; } if (clientClass === 'app' && subtopic === 'log/points') { await this.appLogIngest.handle(orgId, clientId, payload); return; } this.logger.debug( `Unhandled ul path "${clientClass}/${subtopic}" (org ${orgId}, client ${clientId})`, ); } }