import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from '@nestjs/common' import { CapturesRepository } from './captures.repository' import { DevicesRepository } from '../devices/devices.repository' import { RealtimeGateway } from '../../realtime/gateways/realtime.gateway' import * as fs from 'fs' import * as path from 'path' import * as crypto from 'crypto' export interface UploadedFile { filename: string originalname: string mimetype: string size: number path: string } @Injectable() export class CapturesService { private readonly uploadDir: string private readonly uploadBaseUrl: string constructor( private readonly repo: CapturesRepository, private readonly deviceRepo: DevicesRepository, private readonly realtime: RealtimeGateway, ) { this.uploadDir = process.env['UPLOAD_DIR'] || '/uploads/captures' this.uploadBaseUrl = process.env['UPLOAD_BASE_URL'] || 'http://localhost:3001' fs.mkdirSync(this.uploadDir, { recursive: true }) } private buildImageUrl(fileKey: string | null): string | null { if (!fileKey) return null return `${this.uploadBaseUrl}/uploads/captures/${fileKey}` } private async validateDeviceApiKey(deviceId: string, apiKey: string) { const device = await this.deviceRepo.findDeviceById(deviceId) if (!device) throw new NotFoundException(`Device ${deviceId} not found`) if (!device.apiKeyHash) throw new NotFoundException('Device not provisioned') const validKey = await this.deviceRepo.verifyApiKey(device.apiKeyHash, apiKey) if (!validKey) throw new UnauthorizedException('Invalid API key') if (!device.projectId || !device.orgId) throw new BadRequestException('Device not assigned to a project') return device as Required> } async handleUpload(params: { file: UploadedFile apiKey: string deviceId: string capturedAt: string resolution?: string exposureMs?: number iso?: number aperture?: string gpsLat?: string gpsLng?: string }) { const device = await this.validateDeviceApiKey(params.deviceId, params.apiKey) // Compute checksum const fileBuffer = fs.readFileSync(params.file.path) const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex') // Storage path: /uploads/captures/{projectId}/{YYYY-MM-DD}/{id}.{ext} const dateStr = new Date(params.capturedAt).toISOString().slice(0, 10) const ext = path.extname(params.file.originalname) || '.jpg' const id = crypto.randomUUID().slice(0, 12) const storagePath = path.join(this.uploadDir, device.projectId!, dateStr) fs.mkdirSync(storagePath, { recursive: true }) const fileKey = `${device.projectId!}/${dateStr}/${id}${ext}` const destPath = path.join(this.uploadDir, fileKey) fs.renameSync(params.file.path, destPath) const capture = await this.repo.createCapture({ projectId: device.projectId!, deviceId: params.deviceId, capturedAt: new Date(params.capturedAt), fileKey, checksum, resolution: params.resolution ?? undefined, fileSizeBytes: params.file.size, exposureMs: params.exposureMs ?? undefined, iso: params.iso ?? undefined, aperture: params.aperture ?? undefined, gpsLat: params.gpsLat ?? undefined, gpsLng: params.gpsLng ?? undefined, metadata: { originalFilename: params.file.originalname, uploadedAt: new Date().toISOString() }, }) this.realtime.emitCaptureUploaded(params.deviceId, device.projectId!, capture.id) return { captureId: capture.id, fileKey, checksum, sizeBytes: params.file.size, capturedAt: params.capturedAt, } } async registerCapture(params: { apiKey: string deviceId: string capturedAt: string fileKey: string checksum?: string resolution?: string fileSizeBytes?: number exposureMs?: number iso?: number aperture?: string gpsLat?: string gpsLng?: string }) { const device = await this.validateDeviceApiKey(params.deviceId, params.apiKey) const capture = await this.repo.createCapture({ projectId: device.projectId!, deviceId: params.deviceId, capturedAt: new Date(params.capturedAt), fileKey: params.fileKey, checksum: params.checksum ?? undefined, resolution: params.resolution ?? undefined, fileSizeBytes: params.fileSizeBytes ?? undefined, exposureMs: params.exposureMs ?? undefined, iso: params.iso ?? undefined, aperture: params.aperture ?? undefined, gpsLat: params.gpsLat ?? undefined, gpsLng: params.gpsLng ?? undefined, }) this.realtime.emitCaptureUploaded(params.deviceId, device.projectId!, capture.id) return { captureId: capture.id, fileKey: params.fileKey } } async getCapturesByProject(projectId: string, limit = 100) { const captures = await this.repo.findByProjectId(projectId, limit) return captures.map(c => ({ ...c, imageUrl: this.buildImageUrl(c.fileKey) })) } async getCapturesByDevice(deviceId: string, limit = 100) { const captures = await this.repo.findByDeviceId(deviceId, limit) return captures.map(c => ({ ...c, imageUrl: this.buildImageUrl(c.fileKey) })) } async getCaptureById(id: string) { const capture = await this.repo.findById(id) if (!capture) throw new NotFoundException(`Capture ${id} not found`) return { ...capture, imageUrl: this.buildImageUrl(capture.fileKey) } } async getCaptureStats(projectId: string) { const today = await this.repo.countByProjectToday(projectId) return { totalCaptures: today, todayCaptures: today } } }