|
@@ -3,6 +3,20 @@ import { DevicesRepository } from './devices.repository'
|
|
|
import { DeviceStatus } from '@shared/types'
|
|
import { DeviceStatus } from '@shared/types'
|
|
|
import { HeartbeatDto } from './dto/heartbeat.dto'
|
|
import { HeartbeatDto } from './dto/heartbeat.dto'
|
|
|
import { RealtimeGateway } from '../../realtime/gateways/realtime.gateway'
|
|
import { RealtimeGateway } from '../../realtime/gateways/realtime.gateway'
|
|
|
|
|
+import { nanoid } from 'nanoid'
|
|
|
|
|
+
|
|
|
|
|
+function generateClaimCode(): string {
|
|
|
|
|
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
|
|
|
|
+ let code = ''
|
|
|
|
|
+ for (let i = 0; i < 6; i++) {
|
|
|
|
|
+ code += chars[Math.floor(Math.random() * chars.length)]
|
|
|
|
|
+ }
|
|
|
|
|
+ return code
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function generateApiKey(): string {
|
|
|
|
|
+ return nanoid(32)
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
@Injectable()
|
|
@Injectable()
|
|
|
export class DevicesService {
|
|
export class DevicesService {
|
|
@@ -11,13 +25,84 @@ export class DevicesService {
|
|
|
private readonly realtime: RealtimeGateway,
|
|
private readonly realtime: RealtimeGateway,
|
|
|
) {}
|
|
) {}
|
|
|
|
|
|
|
|
- async createHeartbeat(dto: HeartbeatDto) {
|
|
|
|
|
- const device = await this.repo.findDeviceById(dto.deviceId)
|
|
|
|
|
- if (!device) {
|
|
|
|
|
- throw new NotFoundException(`Device ${dto.deviceId} not found`)
|
|
|
|
|
|
|
+ // ── Provisioning ────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ async claimDevice(dto: { deviceUuid: string; deviceName: string; serialNo?: string }) {
|
|
|
|
|
+ // Check if already claimed
|
|
|
|
|
+ const existing = await this.repo.findByDeviceUuid(dto.deviceUuid)
|
|
|
|
|
+ if (existing) {
|
|
|
|
|
+ if (!existing.claimCode) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ claimCode: '',
|
|
|
|
|
+ status: 'approved' as const,
|
|
|
|
|
+ deviceId: existing.id,
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const status = await this.repo.getClaimStatus(existing.claimCode)
|
|
|
|
|
+ if (status) return status
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (device.apiKeyHash !== dto.apiKey) {
|
|
|
|
|
|
|
+ // Create pending device entry
|
|
|
|
|
+ const claimCode = generateClaimCode()
|
|
|
|
|
+ const expiresAt = new Date()
|
|
|
|
|
+ expiresAt.setHours(expiresAt.getHours() + 24)
|
|
|
|
|
+
|
|
|
|
|
+ await this.repo.createPendingDevice({
|
|
|
|
|
+ deviceUuid: dto.deviceUuid,
|
|
|
|
|
+ deviceName: dto.deviceName,
|
|
|
|
|
+ serialNo: dto.serialNo ?? dto.deviceUuid,
|
|
|
|
|
+ claimCode,
|
|
|
|
|
+ expiresAt,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ claimCode,
|
|
|
|
|
+ status: 'pending' as const,
|
|
|
|
|
+ expiresAt: expiresAt.toISOString(),
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async getClaimStatus(claimCode: string) {
|
|
|
|
|
+ return this.repo.getClaimStatus(claimCode)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async getPendingDevices() {
|
|
|
|
|
+ return this.repo.findPendingDevices()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async approveDevice(
|
|
|
|
|
+ claimCode: string,
|
|
|
|
|
+ actorUserId: string,
|
|
|
|
|
+ projectId: string,
|
|
|
|
|
+ ) {
|
|
|
|
|
+ const claim = await this.repo.getClaimStatus(claimCode)
|
|
|
|
|
+ if (!claim) throw new NotFoundException('Claim not found')
|
|
|
|
|
+ if (claim.status !== 'pending') throw new NotFoundException('Claim not pending')
|
|
|
|
|
+
|
|
|
|
|
+ const apiKey = generateApiKey()
|
|
|
|
|
+ await this.repo.approvePendingDevice(claimCode, apiKey, actorUserId, projectId)
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ status: 'approved' as const,
|
|
|
|
|
+ apiKey,
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async rejectDevice(claimCode: string) {
|
|
|
|
|
+ const claim = await this.repo.getClaimStatus(claimCode)
|
|
|
|
|
+ if (!claim) throw new NotFoundException('Claim not found')
|
|
|
|
|
+ await this.repo.updatePendingStatus(claimCode)
|
|
|
|
|
+ return { status: 'rejected' as const }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── Heartbeat ──────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ async createHeartbeat(dto: HeartbeatDto, apiKey: string) {
|
|
|
|
|
+ const device = await this.repo.findDeviceById(dto.deviceId)
|
|
|
|
|
+ if (!device) throw new NotFoundException(`Device ${dto.deviceId} not found`)
|
|
|
|
|
+
|
|
|
|
|
+ if (!device.apiKeyHash || device.apiKeyHash !== apiKey) {
|
|
|
throw new UnauthorizedException('Invalid API key')
|
|
throw new UnauthorizedException('Invalid API key')
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -39,23 +124,23 @@ export class DevicesService {
|
|
|
|
|
|
|
|
const pendingCommands = await this.repo.getPendingCommandCount(dto.deviceId)
|
|
const pendingCommands = await this.repo.getPendingCommandCount(dto.deviceId)
|
|
|
|
|
|
|
|
- // Emit real-time events
|
|
|
|
|
- this.realtime.emitDeviceHeartbeat(
|
|
|
|
|
- dto.deviceId,
|
|
|
|
|
- device.projectId,
|
|
|
|
|
- device.orgId,
|
|
|
|
|
- dto.status as DeviceStatus,
|
|
|
|
|
- dto.storageFreeGb,
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- // Emit status change if status actually changed
|
|
|
|
|
- if (previousStatus !== dto.status) {
|
|
|
|
|
- this.realtime.emitDeviceStatusChanged(
|
|
|
|
|
|
|
+ if (device.projectId && device.orgId) {
|
|
|
|
|
+ this.realtime.emitDeviceHeartbeat(
|
|
|
dto.deviceId,
|
|
dto.deviceId,
|
|
|
device.projectId,
|
|
device.projectId,
|
|
|
|
|
+ device.orgId,
|
|
|
dto.status as DeviceStatus,
|
|
dto.status as DeviceStatus,
|
|
|
- previousStatus as DeviceStatus,
|
|
|
|
|
|
|
+ dto.storageFreeGb,
|
|
|
)
|
|
)
|
|
|
|
|
+
|
|
|
|
|
+ if (previousStatus !== dto.status) {
|
|
|
|
|
+ this.realtime.emitDeviceStatusChanged(
|
|
|
|
|
+ dto.deviceId,
|
|
|
|
|
+ device.projectId,
|
|
|
|
|
+ dto.status as DeviceStatus,
|
|
|
|
|
+ previousStatus as DeviceStatus,
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
@@ -65,6 +150,8 @@ export class DevicesService {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ── Query ─────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
async getDevices(projectId?: string) {
|
|
async getDevices(projectId?: string) {
|
|
|
if (projectId) return this.repo.findDevicesByProjectId(projectId)
|
|
if (projectId) return this.repo.findDevicesByProjectId(projectId)
|
|
|
return this.repo.findAllDevices()
|
|
return this.repo.findAllDevices()
|