captures.service.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from '@nestjs/common'
  2. import { CapturesRepository } from './captures.repository'
  3. import { DevicesRepository } from '../devices/devices.repository'
  4. import { RealtimeGateway } from '../../realtime/gateways/realtime.gateway'
  5. import * as fs from 'fs'
  6. import * as path from 'path'
  7. import * as crypto from 'crypto'
  8. export interface UploadedFile {
  9. filename: string
  10. originalname: string
  11. mimetype: string
  12. size: number
  13. path: string
  14. }
  15. @Injectable()
  16. export class CapturesService {
  17. private readonly uploadDir: string
  18. private readonly uploadBaseUrl: string
  19. constructor(
  20. private readonly repo: CapturesRepository,
  21. private readonly deviceRepo: DevicesRepository,
  22. private readonly realtime: RealtimeGateway,
  23. ) {
  24. this.uploadDir = process.env['UPLOAD_DIR'] || '/uploads/captures'
  25. this.uploadBaseUrl = process.env['UPLOAD_BASE_URL'] || 'http://localhost:3001'
  26. fs.mkdirSync(this.uploadDir, { recursive: true })
  27. }
  28. private buildImageUrl(fileKey: string | null): string | null {
  29. if (!fileKey) return null
  30. return `${this.uploadBaseUrl}/uploads/captures/${fileKey}`
  31. }
  32. private async validateDeviceApiKey(deviceId: string, apiKey: string) {
  33. const device = await this.deviceRepo.findDeviceById(deviceId)
  34. if (!device) throw new NotFoundException(`Device ${deviceId} not found`)
  35. if (!device.apiKeyHash) throw new NotFoundException('Device not provisioned')
  36. const validKey = await this.deviceRepo.verifyApiKey(device.apiKeyHash, apiKey)
  37. if (!validKey) throw new UnauthorizedException('Invalid API key')
  38. if (!device.projectId || !device.orgId) throw new BadRequestException('Device not assigned to a project')
  39. return device as Required<Pick<typeof device, 'id' | 'projectId' | 'orgId' | 'serialNo' | 'name'>>
  40. }
  41. async handleUpload(params: {
  42. file: UploadedFile
  43. apiKey: string
  44. deviceId: string
  45. capturedAt: string
  46. resolution?: string
  47. exposureMs?: number
  48. iso?: number
  49. aperture?: string
  50. gpsLat?: string
  51. gpsLng?: string
  52. }) {
  53. const device = await this.validateDeviceApiKey(params.deviceId, params.apiKey)
  54. // Compute checksum
  55. const fileBuffer = fs.readFileSync(params.file.path)
  56. const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex')
  57. // Storage path: /uploads/captures/{projectId}/{YYYY-MM-DD}/{id}.{ext}
  58. const dateStr = new Date(params.capturedAt).toISOString().slice(0, 10)
  59. const ext = path.extname(params.file.originalname) || '.jpg'
  60. const id = crypto.randomUUID().slice(0, 12)
  61. const storagePath = path.join(this.uploadDir, device.projectId!, dateStr)
  62. fs.mkdirSync(storagePath, { recursive: true })
  63. const fileKey = `${device.projectId!}/${dateStr}/${id}${ext}`
  64. const destPath = path.join(this.uploadDir, fileKey)
  65. fs.renameSync(params.file.path, destPath)
  66. const capture = await this.repo.createCapture({
  67. projectId: device.projectId!,
  68. deviceId: params.deviceId,
  69. capturedAt: new Date(params.capturedAt),
  70. fileKey,
  71. checksum,
  72. resolution: params.resolution ?? undefined,
  73. fileSizeBytes: params.file.size,
  74. exposureMs: params.exposureMs ?? undefined,
  75. iso: params.iso ?? undefined,
  76. aperture: params.aperture ?? undefined,
  77. gpsLat: params.gpsLat ?? undefined,
  78. gpsLng: params.gpsLng ?? undefined,
  79. metadata: { originalFilename: params.file.originalname, uploadedAt: new Date().toISOString() },
  80. })
  81. this.realtime.emitCaptureUploaded(params.deviceId, device.projectId!, capture.id)
  82. return {
  83. captureId: capture.id,
  84. fileKey,
  85. checksum,
  86. sizeBytes: params.file.size,
  87. capturedAt: params.capturedAt,
  88. }
  89. }
  90. async registerCapture(params: {
  91. apiKey: string
  92. deviceId: string
  93. capturedAt: string
  94. fileKey: string
  95. checksum?: string
  96. resolution?: string
  97. fileSizeBytes?: number
  98. exposureMs?: number
  99. iso?: number
  100. aperture?: string
  101. gpsLat?: string
  102. gpsLng?: string
  103. }) {
  104. const device = await this.validateDeviceApiKey(params.deviceId, params.apiKey)
  105. const capture = await this.repo.createCapture({
  106. projectId: device.projectId!,
  107. deviceId: params.deviceId,
  108. capturedAt: new Date(params.capturedAt),
  109. fileKey: params.fileKey,
  110. checksum: params.checksum ?? undefined,
  111. resolution: params.resolution ?? undefined,
  112. fileSizeBytes: params.fileSizeBytes ?? undefined,
  113. exposureMs: params.exposureMs ?? undefined,
  114. iso: params.iso ?? undefined,
  115. aperture: params.aperture ?? undefined,
  116. gpsLat: params.gpsLat ?? undefined,
  117. gpsLng: params.gpsLng ?? undefined,
  118. })
  119. this.realtime.emitCaptureUploaded(params.deviceId, device.projectId!, capture.id)
  120. return { captureId: capture.id, fileKey: params.fileKey }
  121. }
  122. async getCapturesByProject(projectId: string, limit = 100) {
  123. const captures = await this.repo.findByProjectId(projectId, limit)
  124. return captures.map(c => ({ ...c, imageUrl: this.buildImageUrl(c.fileKey) }))
  125. }
  126. async getCapturesByDevice(deviceId: string, limit = 100) {
  127. const captures = await this.repo.findByDeviceId(deviceId, limit)
  128. return captures.map(c => ({ ...c, imageUrl: this.buildImageUrl(c.fileKey) }))
  129. }
  130. async getCaptureById(id: string) {
  131. const capture = await this.repo.findById(id)
  132. if (!capture) throw new NotFoundException(`Capture ${id} not found`)
  133. return { ...capture, imageUrl: this.buildImageUrl(capture.fileKey) }
  134. }
  135. async getCaptureStats(projectId: string) {
  136. const today = await this.repo.countByProjectToday(projectId)
  137. return { totalCaptures: today, todayCaptures: today }
  138. }
  139. }