|
|
@@ -1,7 +1,7 @@
|
|
|
-import { Injectable } from '@nestjs/common'
|
|
|
-import { eq, and, desc, sql, gte, isNull } from 'drizzle-orm'
|
|
|
+import { Injectable, NotFoundException } from '@nestjs/common'
|
|
|
+import { eq, and, desc, sql, gte, isNull, isNotNull } from 'drizzle-orm'
|
|
|
import { db } from '../../db/database.module'
|
|
|
-import { devices, deviceHeartbeats, commands } from '../../db/schema'
|
|
|
+import { devices, deviceHeartbeats, commands, projects } from '../../db/schema'
|
|
|
import { nanoid } from 'nanoid'
|
|
|
import * as bcrypt from 'bcrypt'
|
|
|
|
|
|
@@ -19,7 +19,6 @@ export class DevicesRepository {
|
|
|
deviceName: string
|
|
|
serialNo: string
|
|
|
claimCode: string
|
|
|
- expiresAt: Date
|
|
|
}) {
|
|
|
const id = nanoid()
|
|
|
await db.insert(devices).values({
|
|
|
@@ -28,11 +27,10 @@ export class DevicesRepository {
|
|
|
name: data.deviceName,
|
|
|
serialNo: data.serialNo,
|
|
|
claimCode: data.claimCode,
|
|
|
- // No project/org yet — pending approval
|
|
|
projectId: null,
|
|
|
orgId: null,
|
|
|
- apiKeyHash: '', // filled on approval
|
|
|
- status: 'pending',
|
|
|
+ apiKeyHash: '',
|
|
|
+ status: 'pending' as any,
|
|
|
})
|
|
|
return this.findDeviceById(id)
|
|
|
}
|
|
|
@@ -48,35 +46,37 @@ export class DevicesRepository {
|
|
|
if (!device) return null
|
|
|
|
|
|
const now = new Date()
|
|
|
- const expired = device.updatedAt && device.updatedAt < now
|
|
|
-
|
|
|
- let status: 'pending' | 'approved' | 'rejected' | 'expired' = device.status as any
|
|
|
-
|
|
|
- if (device.status === 'pending' && device.projectId === null) {
|
|
|
- // Pending but check expiry — for now allow 24h
|
|
|
- // Compare claim creation time: use updatedAt as proxy
|
|
|
- const created = new Date(device.updatedAt)
|
|
|
- const expiry = new Date(created.getTime() + 24 * 60 * 60 * 1000)
|
|
|
- if (now > expiry) status = 'expired'
|
|
|
+ const created = new Date(device.createdAt)
|
|
|
+ const expiry = new Date(created.getTime() + 24 * 60 * 60 * 1000)
|
|
|
+
|
|
|
+ if (device.projectId === null && device.status === 'pending' && now > expiry) {
|
|
|
+ return {
|
|
|
+ claimCode: device.claimCode,
|
|
|
+ status: 'expired' as const,
|
|
|
+ expiresAt: expiry.toISOString(),
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- if (device.status === 'pending' && device.projectId !== null && device.apiKeyHash !== '') {
|
|
|
- status = 'approved'
|
|
|
- }
|
|
|
+ // Approved when assigned to project and key generated
|
|
|
+ const approved = !!device.projectId && !!device.orgId && !!device.apiKeyHash
|
|
|
|
|
|
return {
|
|
|
claimCode: device.claimCode,
|
|
|
- status,
|
|
|
- deviceId: status === 'approved' ? device.id : undefined,
|
|
|
- apiKey: undefined, // never returned from DB
|
|
|
- expiresAt: device.updatedAt
|
|
|
- ? new Date(device.updatedAt.getTime() + 24 * 60 * 60 * 1000).toISOString()
|
|
|
- : undefined,
|
|
|
+ status: approved ? ('approved' as const) : ('pending' as const),
|
|
|
+ deviceId: approved ? device.id : undefined,
|
|
|
+ apiKey: approved ? (device.bootstrapApiKey ?? undefined) : undefined,
|
|
|
+ expiresAt: expiry.toISOString(),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ async consumeBootstrapApiKey(claimCode: string) {
|
|
|
+ await db
|
|
|
+ .update(devices)
|
|
|
+ .set({ bootstrapApiKey: null, bootstrapExpiresAt: null, claimCode: null, updatedAt: new Date() })
|
|
|
+ .where(eq(devices.claimCode, claimCode))
|
|
|
+ }
|
|
|
+
|
|
|
async findPendingDevices() {
|
|
|
- // Devices with status=pending and no project assigned
|
|
|
return db
|
|
|
.select()
|
|
|
.from(devices)
|
|
|
@@ -84,6 +84,7 @@ export class DevicesRepository {
|
|
|
and(
|
|
|
eq(devices.status, 'pending' as any),
|
|
|
isNull(devices.projectId),
|
|
|
+ isNotNull(devices.claimCode),
|
|
|
),
|
|
|
)
|
|
|
.orderBy(desc(devices.createdAt))
|
|
|
@@ -92,17 +93,25 @@ export class DevicesRepository {
|
|
|
async approvePendingDevice(
|
|
|
claimCode: string,
|
|
|
apiKeyPlain: string,
|
|
|
- actorUserId: string,
|
|
|
+ _actorUserId: string,
|
|
|
projectId: string,
|
|
|
) {
|
|
|
+ const project = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1)
|
|
|
+ const targetProject = project[0]
|
|
|
+ if (!targetProject) throw new NotFoundException('Project not found')
|
|
|
+
|
|
|
const apiKeyHash = await bcrypt.hash(apiKeyPlain, 10)
|
|
|
+ const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000)
|
|
|
+
|
|
|
await db
|
|
|
.update(devices)
|
|
|
.set({
|
|
|
apiKeyHash,
|
|
|
- projectId,
|
|
|
- status: 'offline',
|
|
|
- claimCode: null,
|
|
|
+ bootstrapApiKey: apiKeyPlain,
|
|
|
+ bootstrapExpiresAt: expiresAt,
|
|
|
+ projectId: targetProject.id,
|
|
|
+ orgId: targetProject.orgId,
|
|
|
+ status: 'offline' as any,
|
|
|
updatedAt: new Date(),
|
|
|
})
|
|
|
.where(eq(devices.claimCode, claimCode))
|
|
|
@@ -115,14 +124,6 @@ export class DevicesRepository {
|
|
|
.where(eq(devices.claimCode, claimCode))
|
|
|
}
|
|
|
|
|
|
- async assignProject(deviceId: string, projectId: string, orgId: string, apiKeyPlain: string) {
|
|
|
- const apiKeyHash = await bcrypt.hash(apiKeyPlain, 10)
|
|
|
- await db
|
|
|
- .update(devices)
|
|
|
- .set({ projectId, orgId, apiKeyHash, status: 'offline' as any, claimCode: null, updatedAt: new Date() })
|
|
|
- .where(eq(devices.id, deviceId))
|
|
|
- }
|
|
|
-
|
|
|
// ── Heartbeat ──────────────────────────────────────────
|
|
|
|
|
|
async findDeviceById(id: string) {
|
|
|
@@ -130,9 +131,9 @@ export class DevicesRepository {
|
|
|
return result[0] ?? null
|
|
|
}
|
|
|
|
|
|
- async findBySerialNo(serialNo: string) {
|
|
|
- const result = await db.select().from(devices).where(eq(devices.serialNo, serialNo)).limit(1)
|
|
|
- return result[0] ?? null
|
|
|
+ async verifyApiKey(hash: string, apiKey: string) {
|
|
|
+ if (!hash) return false
|
|
|
+ return bcrypt.compare(apiKey, hash)
|
|
|
}
|
|
|
|
|
|
async updateDeviceStatus(id: string, status: string, lastSeenAt: Date, firmwareVersion?: string) {
|