Build core domain: orgs, users, jobs, locate points, auth, live map
Replaces the device-events demo with the actual product:
- Prisma + PostGIS data layer (postgis/postgis:17-3.5). Lat/lng decimals
are the source of truth; a generated geometry(Point,4326) column with
a GIST index backs bbox queries. Migrations apply on container boot.
- JWT auth (bcryptjs + httpOnly cookie) with public registration that
creates an org; per-org roles (ORG_ADMIN/MEMBER/VIEWER) enforced by
guards on all /orgs/:orgId routes.
- Scoped API keys (X-API-Key, sha256-hashed, shown once) for
programmatic access, manageable by org admins.
- REST API: jobs/tickets CRUD with filters, points query (time range,
recordedAt cursor, bbox), members, devices, api-keys.
- MQTT ingest: devices publish to devices/{username}/points and /jobs;
unknown tickets auto-create stub jobs (source=DEVICE); every message
is raw-logged to device_events; acks on devices/{username}/jobs/ack.
Broker gets a dedicated backend user; testuser is now a plain device.
- Realtime: plain-WS gateway at /api/ws (socket.io removed) with
cookie auth and per-job channels feeding the map live.
- Next.js frontend: login/register, jobs list with filters, job detail
with live Google map (APWA utility colors, polylines per run) behind
a provider-neutral JobMap abstraction for a future Esri swap, and
settings pages for members/devices/api-keys.
- Seed: Umagul org, admin user, testuser device, demo job with RTK
points. Sample publisher updated to the new topic contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
63
backend/src/ingest/ingest-router.service.ts
Normal file
63
backend/src/ingest/ingest-router.service.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JobsIngestService } from './jobs-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,
|
||||
) {}
|
||||
|
||||
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) {
|
||||
// Raw log captures every message on devices/#, matched or not (audit/debug trail)
|
||||
await this.prisma.deviceEvent.create({ data: { topic, payload } });
|
||||
|
||||
const [root, username, ...rest] = topic.split('/');
|
||||
const subtopic = rest.join('/');
|
||||
if (root !== 'devices' || !username || subtopic === 'jobs/ack') {
|
||||
return; // not device traffic, or our own ack echoed back
|
||||
}
|
||||
|
||||
const device = await this.prisma.device.findUnique({ where: { mqttUsername: username } });
|
||||
if (!device) {
|
||||
this.logger.warn(`Message from unregistered device username "${username}" (raw-logged only)`);
|
||||
return;
|
||||
}
|
||||
if (!device.isActive) {
|
||||
this.logger.warn(`Message from deactivated device "${username}" 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 ${username}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user