feat(S2-a): ingest durable app MQTT points securely

This commit is contained in:
Brent Perteet
2026-08-20 15:22:40 -05:00
parent daa3407b9d
commit 8bcc12ec96
14 changed files with 979 additions and 59 deletions

View File

@@ -0,0 +1,189 @@
import { LocateMode, UtilityType } from '@prisma/client';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsDateString,
IsEnum,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
export const APP_FIX_TYPES = [
'AUTONOMOUS',
'DGPS',
'FLOAT',
'FIXED',
'NO_FIX',
] as const;
export type AppFixType = (typeof APP_FIX_TYPES)[number];
// Frozen app MQTT wire profile v1 from meta/contracts/telemetry-schema.md. This is deliberately
// separate from MqttPointDto: the app wire uses the normative fix tokens FLOAT/FIXED/NO_FIX,
// while the legacy device path uses Prisma's FLOAT_RTK/FIXED_RTK/NONE storage tokens.
export class AppLogPointDto {
@ApiProperty({
format: 'uuid',
description: 'Client-generated UUIDv7 idempotency key.',
})
@IsUUID()
pointId: string;
@ApiProperty({ format: 'date-time' })
@IsDateString()
createdAt: string;
@IsIn(['APP'])
origin: 'APP';
@IsIn(['APP_MQTT'])
uploadPath: 'APP_MQTT';
@IsNumber()
@Min(-90)
@Max(90)
lat: number;
@IsNumber()
@Min(-180)
@Max(180)
lng: number;
@IsOptional()
@IsNumber()
alt?: number;
@IsDateString()
ts: string;
@IsOptional()
@IsIn(APP_FIX_TYPES)
fix?: AppFixType;
@IsOptional()
@IsNumber()
@Min(0)
hAcc?: number;
@IsOptional()
@IsNumber()
@Min(0)
vAcc?: number;
@IsOptional()
@IsInt()
@Min(0)
sats?: number;
@IsOptional()
@IsNumber()
@Min(0)
hdop?: number;
@IsOptional()
@IsNumber()
@Min(0)
depth?: number;
@IsOptional()
@IsInt()
@Min(0)
freqHz?: number;
@IsOptional()
@IsNumber()
@Min(0)
currentMa?: number;
@IsOptional()
@IsNumber()
signalDb?: number;
@IsOptional()
@IsNumber()
gainDb?: number;
@IsOptional()
@IsEnum(LocateMode)
mode?: LocateMode;
@IsOptional()
@IsNumber()
phaseDeg?: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(360)
compassDeg?: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
distortionPct?: number;
@IsOptional()
@IsEnum(UtilityType)
utility?: UtilityType;
@IsOptional()
@IsInt()
seq?: number;
@IsIn(['IN_SPEC', 'OUT_OF_SPEC', 'MANUAL', 'PPK_CORRECTED', 'WAIVED', 'NONCOMPLIANT'])
qualityFlag: 'IN_SPEC' | 'OUT_OF_SPEC' | 'MANUAL' | 'PPK_CORRECTED' | 'WAIVED' | 'NONCOMPLIANT';
}
export class AppLogPointsMessageDto {
@ApiPropertyOptional({ description: 'MQTT app-log wire profile version.' })
@IsString()
@MaxLength(16)
schemaVersion: string;
@ApiPropertyOptional({
description: 'Job id; exactly one of jobId or ticket is required.',
})
@IsOptional()
@IsString()
jobId?: string;
@ApiPropertyOptional({
description: 'Ticket number; exactly one of jobId or ticket is required.',
})
@IsOptional()
@IsString()
@MaxLength(64)
ticket?: string;
@ApiProperty({ type: [AppLogPointDto] })
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => AppLogPointDto)
points: AppLogPointDto[];
}
export type AppLogAckOutcome = 'ACCEPTED' | 'DUPLICATE' | 'REJECTED';
export interface AppLogAckResult {
pointId: string;
outcome: AppLogAckOutcome;
reasonCode?: string;
}
export interface AppLogAck {
schemaVersion: string;
results: AppLogAckResult[];
}