diff --git a/Backend/README.md b/Backend/README.md index 805e966..2ab4a93 100644 --- a/Backend/README.md +++ b/Backend/README.md @@ -83,6 +83,34 @@ All authenticated endpoints expect a Better Auth session cookie sent by the clie `POST /videos/upload-url` request body requires `fileName` and `deviceId` (UUID belonging to the authenticated user), with optional `prefix`. +### Device Management (Phase 1) +| Endpoint | Purpose | +| --- | --- | +| `POST /devices/register` | Register a user-owned device as `camera` or `client` and issue bearer device token | +| `GET /devices` | List all devices for the authenticated user | +| `PATCH /devices/:deviceId` | Update device role/metadata/status | +| `POST /devices/:deviceId/heartbeat` | Device-token authenticated presence heartbeat | + +### Camera-Client Linking (Phase 1) +| Endpoint | Purpose | +| --- | --- | +| `POST /device-links` | Link one client device to one camera device | +| `GET /device-links` | List links for the authenticated user | +| `DELETE /device-links/:linkId` | Remove a camera-client link | + +### Realtime Commands (Phase 2) +| Endpoint | Purpose | +| --- | --- | +| `POST /commands` | Queue and dispatch command from a linked client device to camera | +| `GET /commands` | Inspect command status/history | +| `POST /commands/:commandId/ack` | Device-token ack/reject command fallback | + +Socket.IO channel: +- Devices connect with bearer device token (`auth.token` or `Authorization: Bearer ...`). +- Camera receives `command:received`. +- Camera sends `command:ack` with `acknowledged` or `rejected`. +- Source client receives `command:status`. + ### API Docs OpenAPI docs are generated from Zod/OpenAPI definitions: diff --git a/Backend/docs/openapi.ts b/Backend/docs/openapi.ts index faf18a5..beb8c00 100644 --- a/Backend/docs/openapi.ts +++ b/Backend/docs/openapi.ts @@ -108,6 +108,55 @@ const DeleteVideoResponseSchema = registry.register( }), ); +const DeviceSchema = registry.register( + 'Device', + z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + name: z.string().nullable(), + role: z.enum(['camera', 'client']), + platform: z.string().nullable(), + appVersion: z.string().nullable(), + status: z.string(), + isCamera: z.boolean(), + lastSeenAt: z.string().datetime().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + }), +); + +const DeviceLinkSchema = registry.register( + 'DeviceLink', + z.object({ + id: z.string().uuid(), + ownerUserId: z.string().uuid(), + cameraDeviceId: z.string().uuid(), + clientDeviceId: z.string().uuid(), + status: z.string(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + }), +); + +const DeviceCommandSchema = registry.register( + 'DeviceCommand', + z.object({ + id: z.string().uuid(), + ownerUserId: z.string().uuid(), + sourceDeviceId: z.string().uuid(), + targetDeviceId: z.string().uuid(), + commandType: z.string(), + payload: z.record(z.string(), z.unknown()).nullable(), + status: z.string(), + retryCount: z.number().int(), + lastDispatchedAt: z.string().datetime().nullable(), + acknowledgedAt: z.string().datetime().nullable(), + error: z.string().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + }), +); + registry.registerPath({ method: 'get', path: '/', @@ -403,6 +452,162 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: 'post', + path: '/devices/register', + summary: 'Register a mobile device and issue a device token', + tags: ['Devices'], + security: [{ cookieAuth: [] }], + request: { + body: { + content: { + 'application/json': { + schema: z.object({ + name: z.string().optional(), + role: z.enum(['camera', 'client']), + platform: z.string().optional(), + appVersion: z.string().optional(), + pushToken: z.string().optional(), + }), + }, + }, + }, + }, + responses: { + 201: { + description: 'Device registered', + content: { + 'application/json': { + schema: z.object({ + message: z.string(), + device: DeviceSchema, + deviceToken: z.string(), + }), + }, + }, + }, + }, +}); + +registry.registerPath({ + method: 'get', + path: '/devices', + summary: 'List devices for the authenticated user', + tags: ['Devices'], + security: [{ cookieAuth: [] }], + responses: { + 200: { + description: 'User devices', + content: { + 'application/json': { + schema: z.object({ + count: z.number().int(), + devices: z.array(DeviceSchema), + }), + }, + }, + }, + }, +}); + +registry.registerPath({ + method: 'post', + path: '/devices/{deviceId}/heartbeat', + summary: 'Record heartbeat for a device using bearer device token', + tags: ['Devices'], + security: [{ bearerDeviceToken: [] }], + request: { + params: z.object({ deviceId: z.string().uuid() }), + body: { + content: { + 'application/json': { + schema: z.object({ status: z.enum(['online', 'offline']).optional() }), + }, + }, + }, + }, + responses: { + 200: { + description: 'Heartbeat recorded', + content: { + 'application/json': { + schema: z.object({ + message: z.string(), + device: z.object({ + id: z.string().uuid(), + status: z.string(), + lastSeenAt: z.string().datetime().nullable(), + updatedAt: z.string().datetime(), + }), + }), + }, + }, + }, + }, +}); + +registry.registerPath({ + method: 'post', + path: '/device-links', + summary: 'Link a client device to a camera device', + tags: ['Device Links'], + security: [{ cookieAuth: [] }], + request: { + body: { + content: { + 'application/json': { + schema: z.object({ + cameraDeviceId: z.string().uuid(), + clientDeviceId: z.string().uuid(), + }), + }, + }, + }, + }, + responses: { + 201: { + description: 'Link created', + content: { + 'application/json': { + schema: z.object({ message: z.string(), link: DeviceLinkSchema }), + }, + }, + }, + }, +}); + +registry.registerPath({ + method: 'post', + path: '/commands', + summary: 'Create and dispatch a realtime command from client to camera', + tags: ['Commands'], + security: [{ cookieAuth: [] }], + request: { + body: { + content: { + 'application/json': { + schema: z.object({ + sourceDeviceId: z.string().uuid(), + targetDeviceId: z.string().uuid(), + commandType: z.enum(['start_stream', 'stop_stream', 'ping', 'update_settings']), + payload: z.record(z.string(), z.unknown()).optional(), + }), + }, + }, + }, + }, + responses: { + 201: { + description: 'Command queued', + content: { + 'application/json': { + schema: z.object({ message: z.string(), command: DeviceCommandSchema }), + }, + }, + }, + }, +}); + export function buildOpenApiDocument() { const generator = new OpenApiGeneratorV3(registry.definitions); const document = generator.generateDocument({ @@ -417,6 +622,9 @@ export function buildOpenApiDocument() { { name: 'System', description: 'Service endpoints' }, { name: 'Videos', description: 'Authenticated video object operations' }, { name: 'Admin', description: 'Basic-auth protected admin operations' }, + { name: 'Devices', description: 'Device registration and heartbeat endpoints' }, + { name: 'Device Links', description: 'Client-camera authorization links' }, + { name: 'Commands', description: 'Realtime command dispatch and status' }, ], }); @@ -433,6 +641,10 @@ export function buildOpenApiDocument() { type: 'http', scheme: 'basic', }, + bearerDeviceToken: { + type: 'http', + scheme: 'bearer', + }, }, };