import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { toPointDto } from '../points/points.service'; import { RealtimeService } from '../realtime/realtime.service'; import { DevicePositionSnapshot } from './device-position'; import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto'; @Injectable() export class DevicesService { constructor( private readonly prisma: PrismaService, private readonly realtime: RealtimeService, ) {} list(orgId: string) { return this.prisma.device.findMany({ where: { orgId }, orderBy: { createdAt: 'asc' }, }); } async create(orgId: string, dto: CreateDeviceDto) { if (!dto.mqttUsername && !dto.serialNumber) { throw new BadRequestException('A device needs a serial number, an MQTT username, or both'); } if (dto.mqttUsername) { const existing = await this.prisma.device.findUnique({ where: { mqttUsername: dto.mqttUsername }, }); if (existing) { throw new ConflictException('A device with this MQTT username already exists'); } } if (dto.serialNumber) { const existing = await this.prisma.device.findUnique({ where: { serialNumber: dto.serialNumber }, }); if (existing) { throw new ConflictException('A device with this serial number already exists'); } } const device = await this.prisma.device.create({ data: { orgId, name: dto.name, mqttUsername: dto.mqttUsername, serialNumber: dto.serialNumber }, }); return { ...device, provisioning: device.mqttUsername ? { mqttUsername: device.mqttUsername, pointsTopic: `devices/${device.mqttUsername}/points`, jobsTopic: `devices/${device.mqttUsername}/jobs`, note: 'Broker credentials must be created separately (mosquitto_passwd) until dynamic broker auth lands.', } : { logTopic: device.serialNumber ? `devices/${device.serialNumber}/log` : undefined, note: `Relayed locator: points published by a gateway should carry "serial": "${device.serialNumber}", or a direct-connect locator can publish single readings to devices/${device.serialNumber}/log.`, }, }; } async update(orgId: string, deviceId: string, dto: UpdateDeviceDto) { await this.get(orgId, deviceId); const { disabledReason, ...rest } = dto; const device = await this.prisma.device.update({ where: { id: deviceId }, data: { ...rest, // A reason only makes sense while disabled; re-enabling always clears it. ...(dto.isActive === true && { disabledReason: null }), ...(dto.isActive === false && { disabledReason: disabledReason ?? null }), }, }); this.realtime.publish(`org:${orgId}:devices`, { type: 'device', orgId, deviceId, isActive: device.isActive, disabledReason: device.disabledReason, }); return device; } async remove(orgId: string, deviceId: string) { await this.get(orgId, deviceId); await this.prisma.device.delete({ where: { id: deviceId } }); return { ok: true }; } async getLocation(orgId: string, deviceId: string) { const device = await this.get(orgId, deviceId); const point = await this.prisma.locatePoint.findFirst({ where: { deviceId }, orderBy: { recordedAt: 'desc' }, include: { job: { select: { id: true, ticketNumber: true, title: true } } }, }); // A live "status" ping is never persisted as a LocatePoint, so the most // recent position may only exist on the device's lastPosition snapshot — // compare timestamps and use whichever source is actually newer. const live = device.lastPosition as unknown as DevicePositionSnapshot | null; if (live && device.lastPositionAt && (!point || device.lastPositionAt > point.recordedAt)) { return { point: { id: `live-${deviceId}`, lat: live.lat, lng: live.lng, altitude: live.altitude, utilityType: live.utilityType, fixType: live.fixType, sequence: null, recordedAt: live.recordedAt, hAccuracy: live.hAccuracy, vAccuracy: live.vAccuracy, satellites: live.satellites, hdop: live.hdop, depth: live.depth, frequencyHz: live.frequencyHz, currentMa: live.currentMa, signalDb: live.signalDb, gainDb: live.gainDb, locateMode: live.locateMode, phaseDeg: live.phaseDeg, compassDeg: live.compassDeg, distortionPct: live.distortionPct, }, job: { id: live.jobId, ticketNumber: live.jobTicketNumber, title: live.jobTitle }, }; } if (!point) { return { point: null, job: null }; } const { job, ...rest } = point; return { point: toPointDto(rest), job }; } private async get(orgId: string, deviceId: string) { const device = await this.prisma.device.findFirst({ where: { id: deviceId, orgId } }); if (!device) { throw new NotFoundException('Device not found'); } return device; } }