278 lines
8.6 KiB
TypeScript
278 lines
8.6 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { GpsFixType, Prisma } from '@prisma/client';
|
|
import { plainToInstance } from 'class-transformer';
|
|
import { validate } from 'class-validator';
|
|
import { toPointDto } from '../points/points.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { RealtimeService } from '../realtime/realtime.service';
|
|
import {
|
|
AppFixType,
|
|
AppLogAck,
|
|
AppLogAckResult,
|
|
AppLogPointDto,
|
|
AppLogPointsMessageDto,
|
|
} from './dto/app-messages.dto';
|
|
import { MqttClientService } from './mqtt-client.service';
|
|
|
|
const SCHEMA_VERSION = '1';
|
|
const UUID_V7 =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
|
|
// Durable app-originated ingest for ul/{orgId}/app/{clientId}/log/points. The exact v1
|
|
// publish/ack envelopes are frozen in meta/contracts/telemetry-schema.md.
|
|
@Injectable()
|
|
export class AppLogIngestService {
|
|
private readonly logger = new Logger(AppLogIngestService.name);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly realtime: RealtimeService,
|
|
private readonly mqttClient: MqttClientService,
|
|
) {}
|
|
|
|
async handle(
|
|
orgId: string,
|
|
clientId: string,
|
|
rawPayload: string,
|
|
): Promise<void> {
|
|
const org = await this.prisma.organization.findUnique({
|
|
where: { id: orgId },
|
|
select: { id: true },
|
|
});
|
|
if (!org) {
|
|
this.logger.warn(
|
|
`app-log for unknown org "${orgId}" (client ${clientId}) — dropped`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(rawPayload);
|
|
} catch {
|
|
// There is no trustworthy pointId to acknowledge. Raw app payloads are redacted by the
|
|
// router; retain/log on the app side rather than sending a null/ambiguous acknowledgement.
|
|
this.logger.warn(
|
|
`invalid JSON from app ${orgId}/${clientId} — no acknowledgement emitted`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const msg = plainToInstance(AppLogPointsMessageDto, parsed as object);
|
|
const rawPoints = Array.isArray((parsed as { points?: unknown[] })?.points)
|
|
? (parsed as { points: unknown[] }).points
|
|
: [];
|
|
const usableIds = rawPoints
|
|
.map((value) =>
|
|
typeof (value as { pointId?: unknown })?.pointId === 'string'
|
|
? (value as { pointId: string }).pointId
|
|
: null,
|
|
)
|
|
.filter((value): value is string => value !== null);
|
|
|
|
const hasJobId = typeof msg.jobId === 'string' && msg.jobId.length > 0;
|
|
const hasTicket = typeof msg.ticket === 'string' && msg.ticket.length > 0;
|
|
const envelopeValid =
|
|
msg.schemaVersion === SCHEMA_VERSION &&
|
|
rawPoints.length > 0 &&
|
|
rawPoints.length <= 500 &&
|
|
hasJobId !== hasTicket;
|
|
if (!envelopeValid) {
|
|
this.publishAck(
|
|
orgId,
|
|
clientId,
|
|
usableIds.map((pointId) => ({
|
|
pointId,
|
|
outcome: 'REJECTED',
|
|
reasonCode: 'ENVELOPE_VALIDATION_ERROR',
|
|
})),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Validate independently so one malformed capture does not discard valid captures in the
|
|
// same batch (LOG-7). Invalid UUID strings can still be named in a rejection; missing IDs
|
|
// cannot be safely correlated and therefore remain unacknowledged.
|
|
const results: AppLogAckResult[] = [];
|
|
const candidates: AppLogPointDto[] = [];
|
|
for (const rawPoint of rawPoints) {
|
|
const point = plainToInstance(AppLogPointDto, rawPoint as object);
|
|
const pointId = typeof point.pointId === 'string' ? point.pointId : null;
|
|
const errors = await validate(point, { whitelist: true });
|
|
if (!pointId) {
|
|
continue;
|
|
}
|
|
if (errors.length > 0 || !UUID_V7.test(pointId)) {
|
|
results.push({
|
|
pointId,
|
|
outcome: 'REJECTED',
|
|
reasonCode: UUID_V7.test(pointId)
|
|
? 'VALIDATION_ERROR'
|
|
: 'POINT_ID_NOT_UUIDV7',
|
|
});
|
|
continue;
|
|
}
|
|
candidates.push(point);
|
|
}
|
|
|
|
const job = await this.resolveJob(orgId, clientId, msg);
|
|
if (!job) {
|
|
results.push(
|
|
...candidates.map((point) => ({
|
|
pointId: point.pointId,
|
|
outcome: 'REJECTED' as const,
|
|
reasonCode: 'UNKNOWN_JOB_OR_WRONG_ORG',
|
|
})),
|
|
);
|
|
this.publishAck(orgId, clientId, results);
|
|
return;
|
|
}
|
|
|
|
// Collapse duplicates inside this delivery, then use producer event time rather than
|
|
// arrival/array order. The first valid occurrence wins; repeated IDs are safe to release.
|
|
const byPointId = new Map<string, AppLogPointDto>();
|
|
for (const point of candidates) {
|
|
if (byPointId.has(point.pointId)) {
|
|
results.push({
|
|
pointId: point.pointId,
|
|
outcome: 'DUPLICATE',
|
|
reasonCode: 'DUPLICATE_IN_BATCH',
|
|
});
|
|
} else {
|
|
byPointId.set(point.pointId, point);
|
|
}
|
|
}
|
|
const ordered = [...byPointId.values()].sort(
|
|
(a, b) => +new Date(a.createdAt) - +new Date(b.createdAt),
|
|
);
|
|
|
|
const seen = ordered.length
|
|
? await this.prisma.locatePoint.findMany({
|
|
where: { pointId: { in: ordered.map((point) => point.pointId) } },
|
|
select: { pointId: true },
|
|
})
|
|
: [];
|
|
const seenIds = new Set(seen.map((row) => row.pointId));
|
|
const fresh = ordered.filter((point) => !seenIds.has(point.pointId));
|
|
for (const point of ordered.filter((candidate) =>
|
|
seenIds.has(candidate.pointId),
|
|
)) {
|
|
results.push({ pointId: point.pointId, outcome: 'DUPLICATE' });
|
|
}
|
|
|
|
let stored: Awaited<
|
|
ReturnType<typeof this.prisma.locatePoint.createManyAndReturn>
|
|
> = [];
|
|
if (fresh.length > 0) {
|
|
stored = await this.prisma.locatePoint.createManyAndReturn({
|
|
data: fresh.map((point) => ({
|
|
jobId: job.id,
|
|
deviceId: null,
|
|
pointId: point.pointId,
|
|
origin: 'APP' as const,
|
|
uploadPath: 'APP_MQTT' as const,
|
|
originClientId: clientId,
|
|
createdAt: new Date(point.createdAt),
|
|
lat: point.lat,
|
|
lng: point.lng,
|
|
altitude: point.alt,
|
|
utilityType: point.utility,
|
|
sequence: point.seq,
|
|
fixType: this.toStoredFix(point.fix),
|
|
hAccuracy: point.hAcc,
|
|
vAccuracy: point.vAcc,
|
|
satellites: point.sats,
|
|
hdop: point.hdop,
|
|
depth: point.depth,
|
|
frequencyHz: point.freqHz,
|
|
currentMa: point.currentMa,
|
|
signalDb: point.signalDb,
|
|
gainDb: point.gainDb,
|
|
locateMode: point.mode,
|
|
phaseDeg: point.phaseDeg,
|
|
compassDeg: point.compassDeg,
|
|
distortionPct: point.distortionPct,
|
|
recordedAt: new Date(point.ts),
|
|
raw: point as unknown as Prisma.InputJsonValue,
|
|
})),
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
|
|
const storedIds = new Set(stored.map((row) => row.pointId));
|
|
for (const point of fresh) {
|
|
results.push({
|
|
pointId: point.pointId,
|
|
outcome: storedIds.has(point.pointId) ? 'ACCEPTED' : 'DUPLICATE',
|
|
});
|
|
}
|
|
|
|
if (stored.length > 0) {
|
|
this.realtime.publish(`job:${job.id}`, {
|
|
type: 'points',
|
|
jobId: job.id,
|
|
points: stored.map(toPointDto),
|
|
});
|
|
}
|
|
this.publishAck(orgId, clientId, results);
|
|
}
|
|
|
|
private async resolveJob(
|
|
orgId: string,
|
|
clientId: string,
|
|
msg: AppLogPointsMessageDto,
|
|
) {
|
|
if (msg.jobId) {
|
|
const job = await this.prisma.job.findFirst({
|
|
where: { id: msg.jobId, orgId },
|
|
});
|
|
if (!job)
|
|
this.logger.warn(
|
|
`app-log ${orgId}/${clientId} referenced a foreign/unknown job ${msg.jobId}`,
|
|
);
|
|
return job;
|
|
}
|
|
const existing = await this.prisma.job.findUnique({
|
|
where: { orgId_ticketNumber: { orgId, ticketNumber: msg.ticket! } },
|
|
});
|
|
if (existing) return existing;
|
|
return this.prisma.job.create({
|
|
data: {
|
|
orgId,
|
|
ticketNumber: msg.ticket!,
|
|
title: `Ticket ${msg.ticket} (app-created)`,
|
|
status: 'IN_PROGRESS',
|
|
source: 'WEB',
|
|
startedAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
|
|
private toStoredFix(fix?: AppFixType): GpsFixType | undefined {
|
|
switch (fix) {
|
|
case 'FLOAT':
|
|
return GpsFixType.FLOAT_RTK;
|
|
case 'FIXED':
|
|
return GpsFixType.FIXED_RTK;
|
|
case 'NO_FIX':
|
|
return GpsFixType.NONE;
|
|
case 'AUTONOMOUS':
|
|
return GpsFixType.AUTONOMOUS;
|
|
case 'DGPS':
|
|
return GpsFixType.DGPS;
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private publishAck(
|
|
orgId: string,
|
|
clientId: string,
|
|
results: AppLogAckResult[],
|
|
): void {
|
|
if (results.length === 0) return;
|
|
const ack: AppLogAck = { schemaVersion: SCHEMA_VERSION, results };
|
|
this.mqttClient.publish(`ul/${orgId}/app/${clientId}/ack`, ack);
|
|
}
|
|
}
|