From bd617355799d7ff7379d9dfc605a4af942677329 Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Mon, 13 Apr 2026 19:30:00 +0100 Subject: [PATCH] docs(report): add section 5.3 draft assets --- docs/TEMP_SECTION_5_3_SNIPPETS.md | 1206 +++++++++++++++++ .../5.3.1-server-bootstrap.md | 29 + .../5.3.10-operational-and-support-assets.md | 29 + .../5.3.2-authentication-and-sessions.md | 30 + .../5.3.3-device-registration-and-presence.md | 26 + .../5.3.4-database-schema-and-persistence.md | 97 ++ .../5.3.5-linking-and-control-flow.md | 36 + .../5.3.6-stream-sessions-and-signalling.md | 46 + ...5.3.7-motion-events-notifications-audit.md | 42 + .../5.3.8-recordings-and-object-storage.md | 40 + .../5.3.9-web-application-implementation.md | 53 + docs/temp-section-5-3-diagrams/README.md | 18 + 12 files changed, 1652 insertions(+) create mode 100644 docs/TEMP_SECTION_5_3_SNIPPETS.md create mode 100644 docs/temp-section-5-3-diagrams/5.3.1-server-bootstrap.md create mode 100644 docs/temp-section-5-3-diagrams/5.3.10-operational-and-support-assets.md create mode 100644 docs/temp-section-5-3-diagrams/5.3.2-authentication-and-sessions.md create mode 100644 docs/temp-section-5-3-diagrams/5.3.3-device-registration-and-presence.md create mode 100644 docs/temp-section-5-3-diagrams/5.3.4-database-schema-and-persistence.md create mode 100644 docs/temp-section-5-3-diagrams/5.3.5-linking-and-control-flow.md create mode 100644 docs/temp-section-5-3-diagrams/5.3.6-stream-sessions-and-signalling.md create mode 100644 docs/temp-section-5-3-diagrams/5.3.7-motion-events-notifications-audit.md create mode 100644 docs/temp-section-5-3-diagrams/5.3.8-recordings-and-object-storage.md create mode 100644 docs/temp-section-5-3-diagrams/5.3.9-web-application-implementation.md create mode 100644 docs/temp-section-5-3-diagrams/README.md diff --git a/docs/TEMP_SECTION_5_3_SNIPPETS.md b/docs/TEMP_SECTION_5_3_SNIPPETS.md new file mode 100644 index 0000000..f63c205 --- /dev/null +++ b/docs/TEMP_SECTION_5_3_SNIPPETS.md @@ -0,0 +1,1206 @@ +# Section 5.3 Temporary Code Snippets + +This file collects the main backend and web app snippets referenced in Section 5.3 so they can be screenshotted easily. + +Each snippet includes: +- a short description of what it demonstrates +- a source reference you can cite in the report +- the code excerpt itself + +--- + +## Snippet 1: Backend Bootstrap and Route Mounting + +**What this shows:** +This snippet shows how the backend is assembled in one place. It configures CORS and security middleware, exposes OpenAPI docs, mounts Better Auth, registers the main route groups, and starts the realtime gateway plus background workers. + +**Source:** [Backend/index.ts](/home/matiss/Documents/Final%20Year%20Project/Backend/index.ts:28) + +```ts +const app = express(); +const openApiDocument = buildOpenApiDocument(); +const trustedOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS + ? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean) + : []; +const corsMiddleware = cors({ + origin: trustedOrigins.length > 0 ? trustedOrigins : true, + credentials: true, +}); + +app.get('/openapi.json', (_req, res) => { + res.json(openApiDocument); +}); + +app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument)); + +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + ...helmet.contentSecurityPolicy.getDefaultDirectives(), + "script-src": ["'self'", "'unsafe-inline'", "cdn.jsdelivr.net", "cdn.tailwindcss.com"], + "style-src": ["'self'", "'unsafe-inline'", "cdn.jsdelivr.net", "fonts.googleapis.com"], + "font-src": ["'self'", "fonts.gstatic.com"], + "connect-src": connectSrcDirectives, + "media-src": mediaSrcDirectives, + "img-src": ["'self'", "data:", "blob:"], + }, + }, + }), +); +app.use(corsMiddleware); +app.all('/api/auth/*splat', corsMiddleware, toNodeHandler(auth)); +app.use(rateLimit({ keyPrefix: 'global', windowMs: 60_000, max: 400 })); +app.use(requestContext); +app.use(express.json()); +app.use('/sim', express.static('public')); +app.use('/videos', videosRoutes); +app.use('/admin', adminRoutes); +app.use('/devices', rateLimit({ keyPrefix: 'devices', windowMs: 60_000, max: 120 }), devicesRoutes); +app.use('/device-links', deviceLinksRoutes); +app.use('/commands', rateLimit({ keyPrefix: 'commands', windowMs: 60_000, max: 120 }), commandsRoutes); +app.use('/events', rateLimit({ keyPrefix: 'events', windowMs: 60_000, max: 120 }), eventsRoutes); +app.use('/streams', rateLimit({ keyPrefix: 'streams', windowMs: 60_000, max: 120 }), streamsRoutes); +app.use('/recordings', rateLimit({ keyPrefix: 'recordings', windowMs: 60_000, max: 120 }), recordingsRoutes); +app.use('/push-notifications', pushNotificationsRoutes); +app.use('/audit', auditRoutes); +app.use('/ops', opsRoutes); + +const start = async () => { + try { + await ensureMinioBucket(); + } catch (error) { + console.error('MinIO initialization failed', error); + process.exit(1); + } + + setupRealtimeGateway(server); + startRecordingsWorker(); + startPushWorker(); + + server.listen(port, () => { + console.log(`Server is running on port ${port}`); + }); +}; +``` + +--- + +## Snippet 2: Better Auth Configuration + +**What this shows:** +This snippet shows how session-based user authentication is integrated with the project database through Better Auth and Drizzle. + +**Source:** [Backend/auth.ts](/home/matiss/Documents/Final%20Year%20Project/Backend/auth.ts:13) + +```ts +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: 'pg', + schema: { + ...schema, + user: schema.users, + account: schema.accounts, + session: schema.sessions, + verification: schema.verifications, + }, + }), + advanced: { + database: { + generateId: 'uuid', + }, + }, + emailAndPassword: { + enabled: true, + password: { + hash: async (password) => hashPassword(password), + verify: async ({ hash, password }) => verifyPassword(password, hash), + }, + }, + secret: getRequiredEnv('BETTER_AUTH_SECRET'), + baseURL: getBetterAuthBaseUrl(), + trustedOrigins, +}); +``` + +--- + +## Snippet 3: Core Database Schema + +**What this shows:** +This excerpt shows the main persistence model used by the system: devices, links between camera and client devices, live stream sessions, recordings, motion events, notifications, and audit logs. + +**Source:** [Backend/db/schema.ts](/home/matiss/Documents/Final%20Year%20Project/Backend/db/schema.ts:14) + +```ts +export const devices = pgTable('devices', { + id: uuid('id').defaultRandom().primaryKey(), + userId: uuid('user_id').notNull().references(() => users.id), + name: varchar('name', { length: 255 }), + role: varchar('role', { length: 32 }).default('client').notNull(), + platform: varchar('platform', { length: 32 }), + appVersion: varchar('app_version', { length: 64 }), + pushToken: text('push_token'), + status: varchar('status', { length: 32 }).default('offline').notNull(), + isCamera: boolean('is_camera').default(false).notNull(), + lastSeenAt: timestamp('last_seen_at', { withTimezone: true }), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export const deviceLinks = pgTable( + 'device_links', + { + id: uuid('id').defaultRandom().primaryKey(), + ownerUserId: uuid('owner_user_id').notNull().references(() => users.id), + cameraDeviceId: uuid('camera_device_id').notNull().references(() => devices.id), + clientDeviceId: uuid('client_device_id').notNull().references(() => devices.id), + status: varchar('status', { length: 32 }).default('active').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + uniqueDevicePair: unique('device_links_camera_client_unique').on(table.cameraDeviceId, table.clientDeviceId), + }), +); + +export const streamSessions = pgTable('stream_sessions', { + id: uuid('id').defaultRandom().primaryKey(), + ownerUserId: uuid('owner_user_id').notNull().references(() => users.id), + cameraDeviceId: uuid('camera_device_id').notNull().references(() => devices.id), + requesterDeviceId: uuid('requester_device_id').notNull().references(() => devices.id), + status: varchar('status', { length: 32 }).default('requested').notNull(), + reason: varchar('reason', { length: 32 }).default('on_demand').notNull(), + mediaProvider: varchar('media_provider', { length: 32 }).default('mock').notNull(), + mediaSessionId: varchar('media_session_id', { length: 255 }), + publishEndpoint: text('publish_endpoint'), + subscribeEndpoint: text('subscribe_endpoint'), + streamKey: varchar('stream_key', { length: 255 }), + startedAt: timestamp('started_at', { withTimezone: true }), + endedAt: timestamp('ended_at', { withTimezone: true }), + metadata: jsonb('metadata').$type | null>().default(null), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export const recordings = pgTable('recordings', { + id: uuid('id').defaultRandom().primaryKey(), + ownerUserId: uuid('owner_user_id').notNull().references(() => users.id), + streamSessionId: uuid('stream_session_id').references(() => streamSessions.id), + cameraDeviceId: uuid('camera_device_id').notNull().references(() => devices.id), + requesterDeviceId: uuid('requester_device_id').references(() => devices.id), + eventId: uuid('event_id').references(() => events.id), + objectKey: varchar('object_key', { length: 1024 }), + bucket: varchar('bucket', { length: 255 }), + durationSeconds: integer('duration_seconds'), + sizeBytes: integer('size_bytes'), + status: varchar('status', { length: 32 }).default('awaiting_upload').notNull(), + availableAt: timestamp('available_at', { withTimezone: true }), + error: text('error'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export const events = pgTable('events', { + id: uuid('id').defaultRandom().primaryKey(), + userId: uuid('user_id').notNull().references(() => users.id), + deviceId: uuid('device_id').references(() => devices.id), + title: varchar('title', { length: 255 }), + triggeredBy: varchar('triggered_by', { length: 64 }).default('motion'), + status: varchar('status', { length: 32 }).default('recording').notNull(), + startedAt: timestamp('started_at', { withTimezone: true }).notNull(), + endedAt: timestamp('ended_at', { withTimezone: true }), + notifiedAt: timestamp('notified_at', { withTimezone: true }), + videoUrl: varchar('video_url', { length: 1024 }).unique(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export const notifications = pgTable('notifications', { + id: uuid('id').defaultRandom().primaryKey(), + eventId: uuid('event_id').references(() => events.id).notNull(), + userId: uuid('user_id').references(() => users.id).notNull(), + sentAt: timestamp('sent_at', { withTimezone: true }).defaultNow().notNull(), + channel: varchar('channel', { length: 32 }).notNull(), + status: varchar('status', { length: 32 }).default('queued').notNull(), + isRead: boolean('is_read').default(false).notNull(), +}); + +export const notificationDeliveries = pgTable('notification_deliveries', { + id: uuid('id').defaultRandom().primaryKey(), + ownerUserId: uuid('owner_user_id').notNull().references(() => users.id), + recipientDeviceId: uuid('recipient_device_id').notNull().references(() => devices.id), + type: varchar('type', { length: 64 }).notNull(), + payload: jsonb('payload').$type | null>().default(null), + status: varchar('status', { length: 32 }).default('queued').notNull(), + attempts: integer('attempts').default(0).notNull(), + lastError: text('last_error'), + sentAt: timestamp('sent_at', { withTimezone: true }), + nextAttemptAt: timestamp('next_attempt_at', { withTimezone: true }).defaultNow().notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export const auditLogs = pgTable('audit_logs', { + id: uuid('id').defaultRandom().primaryKey(), + ownerUserId: uuid('owner_user_id').notNull().references(() => users.id), + actorDeviceId: uuid('actor_device_id').references(() => devices.id), + action: varchar('action', { length: 128 }).notNull(), + targetType: varchar('target_type', { length: 64 }).notNull(), + targetId: varchar('target_id', { length: 255 }).notNull(), + metadata: jsonb('metadata').$type | null>().default(null), + ipAddress: text('ip_address'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}); +``` + +--- + +## Snippet 4: Device Registration and Token Issuance + +**What this shows:** +This route creates a device record, automatically links it to opposite-role devices under the same account, and returns a signed device token used for device-level authentication. + +**Source:** [Backend/routes/devices.ts](/home/matiss/Documents/Final%20Year%20Project/Backend/routes/devices.ts:41) + +```ts +router.post('/register', requireAuth, async (req, res) => { + const parsed = registerSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() }); + return; + } + + const authSession = req.auth; + + if (!authSession?.user?.id) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + const now = new Date(); + const [device] = await db + .insert(devices) + .values({ + userId: authSession.user.id, + name: parsed.data.name, + role: parsed.data.role, + isCamera: parsed.data.role === 'camera', + platform: parsed.data.platform, + appVersion: parsed.data.appVersion, + pushToken: parsed.data.pushToken, + status: 'online', + lastSeenAt: now, + updatedAt: now, + }) + .returning({ + id: devices.id, + userId: devices.userId, + name: devices.name, + role: devices.role, + status: devices.status, + platform: devices.platform, + appVersion: devices.appVersion, + lastSeenAt: devices.lastSeenAt, + createdAt: devices.createdAt, + updatedAt: devices.updatedAt, + }); + + if (!device) { + res.status(500).json({ message: 'Unable to register device' }); + return; + } + + const oppositeRole = device.role === 'camera' ? 'client' : 'camera'; + const oppositeDevices = await db.query.devices.findMany({ + where: and(eq(devices.userId, device.userId), eq(devices.role, oppositeRole)), + }); + + if (oppositeDevices.length > 0) { + const linksToCreate = oppositeDevices.map((otherDevice) => ({ + ownerUserId: device.userId, + cameraDeviceId: device.role === 'camera' ? device.id : otherDevice.id, + clientDeviceId: device.role === 'client' ? device.id : otherDevice.id, + status: 'active' as const, + })); + + await db.insert(deviceLinks).values(linksToCreate).onConflictDoNothing(); + } + + const deviceToken = createDeviceToken({ + userId: device.userId, + deviceId: device.id, + role: device.role as 'camera' | 'client', + }); + + res.status(201).json({ + message: 'Device registered', + device, + deviceToken, + }); +}); +``` + +--- + +## Snippet 5: Stream Request Lifecycle + +**What this shows:** +This route validates the client device, confirms the target is a linked camera, inserts a `stream_sessions` row, and emits a realtime `stream:requested` event in simplified mode. + +**Source:** [Backend/routes/streams.ts](/home/matiss/Documents/Final%20Year%20Project/Backend/routes/streams.ts:97) + +```ts +router.post('/request', requireDeviceAuth, async (req, res) => { + const parsed = requestStreamSchema.safeParse(req.body ?? {}); + + if (!parsed.success) { + res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() }); + return; + } + + const deviceAuth = ensureDeviceAuth(req, res); + if (!deviceAuth) return; + + const [sourceDevice, cameraDevice] = await Promise.all([ + db.query.devices.findFirst({ + where: and(eq(devices.id, deviceAuth.deviceId), eq(devices.userId, deviceAuth.userId)), + }), + db.query.devices.findFirst({ + where: and(eq(devices.id, parsed.data.cameraDeviceId), eq(devices.userId, deviceAuth.userId)), + }), + ]); + + if (!sourceDevice || !cameraDevice) { + res.status(404).json({ message: 'Source or camera device not found' }); + return; + } + + if (sourceDevice.role !== 'client') { + res.status(403).json({ message: 'Only client devices can request on-demand stream sessions' }); + return; + } + + if (cameraDevice.role !== 'camera') { + res.status(400).json({ message: 'cameraDeviceId must point to a camera device' }); + return; + } + + const link = await db.query.deviceLinks.findFirst({ + where: and( + eq(deviceLinks.ownerUserId, deviceAuth.userId), + eq(deviceLinks.cameraDeviceId, cameraDevice.id), + eq(deviceLinks.clientDeviceId, sourceDevice.id), + eq(deviceLinks.status, 'active'), + ), + }); + + if (!link) { + res.status(403).json({ message: 'No active link between requester and camera' }); + return; + } + + const now = new Date(); + + const [session] = await db + .insert(streamSessions) + .values({ + ownerUserId: deviceAuth.userId, + cameraDeviceId: cameraDevice.id, + requesterDeviceId: sourceDevice.id, + status: 'requested', + reason: parsed.data.reason, + metadata: parsed.data.metadata ?? null, + mediaProvider: mediaProvider.name, + updatedAt: now, + }) + .returning(); + + if (!session) { + res.status(500).json({ message: 'Failed creating stream session' }); + return; + } + + if (simpleStreamingEnabled) { + const requestPayload = createStreamRequestedPayload(session); + const deliveredToCamera = sendRealtimeToDevice(cameraDevice.id, 'stream:requested', requestPayload); + + console.info('[stream.request]', { + streamSessionId: session.id, + requesterDeviceId: sourceDevice.id, + cameraDeviceId: cameraDevice.id, + mode: 'simple', + }); + + sendRealtimeToDevice(sourceDevice.id, 'stream:requested', requestPayload); + + if (!deliveredToCamera) { + await enqueuePushNotification({ + ownerUserId: cameraDevice.userId, + recipientDeviceId: cameraDevice.id, + type: 'stream_requested', + payload: requestPayload, + }); + } + + res.status(201).json({ + message: 'Stream request sent', + streamSession: toSimpleStreamSessionResponse(session), + }); + + await writeAuditLog({ + ownerUserId: sourceDevice.userId, + actorDeviceId: sourceDevice.id, + action: 'stream.requested', + targetType: 'stream_session', + targetId: session.id, + metadata: { cameraDeviceId: cameraDevice.id, reason: session.reason, transport: 'webrtc' }, + ipAddress: req.ip, + }); + return; + } +``` + +--- + +## Snippet 6: Stream Accept Lifecycle + +**What this shows:** +This route moves a stream session from `requested` to `streaming`, records start metadata, optionally initializes SFU state, and emits `stream:started` to the viewer. + +**Source:** [Backend/routes/streams.ts](/home/matiss/Documents/Final%20Year%20Project/Backend/routes/streams.ts:276) + +```ts +router.post('/:streamSessionId/accept', requireDeviceAuth, async (req, res) => { + const parsedParams = streamParamSchema.safeParse(req.params); + + if (!parsedParams.success) { + res.status(400).json({ message: 'Invalid streamSessionId', errors: parsedParams.error.flatten() }); + return; + } + + const parsed = acceptStreamSchema.safeParse(req.body ?? {}); + + if (!parsed.success) { + res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() }); + return; + } + + const deviceAuth = ensureDeviceAuth(req, res); + if (!deviceAuth) return; + + const session = await db.query.streamSessions.findFirst({ + where: and( + eq(streamSessions.id, parsedParams.data.streamSessionId), + eq(streamSessions.ownerUserId, deviceAuth.userId), + eq(streamSessions.cameraDeviceId, deviceAuth.deviceId), + ), + }); + + if (!session) { + res.status(404).json({ message: 'Stream session not found for this camera device' }); + return; + } + + if (session.status !== 'requested' && session.status !== 'starting') { + res.status(409).json({ message: `Stream session cannot be accepted from status ${session.status}` }); + return; + } + + const now = new Date(); + const streamKey = parsed.data.streamKey ?? `stream_${session.id}_${randomUUID()}`; + const mediaSession = await createLiveMediaSession({ + streamSessionId: session.id, + ownerUserId: session.ownerUserId, + cameraDeviceId: session.cameraDeviceId, + requesterDeviceId: session.requesterDeviceId, + }); + + const [updated] = await db + .update(streamSessions) + .set({ + status: 'streaming', + streamKey: mediaSession ? streamKey : null, + mediaProvider: mediaSession?.provider ?? 'simple', + mediaSessionId: mediaSession?.mediaSessionId ?? null, + publishEndpoint: mediaSession?.publishUrl ?? null, + subscribeEndpoint: mediaSession?.subscribeUrl ?? null, + metadata: parsed.data.metadata ?? session.metadata, + startedAt: now, + updatedAt: now, + }) + .where(eq(streamSessions.id, session.id)) + .returning(); + + if (!updated) { + res.status(500).json({ message: 'Failed to update stream session' }); + return; + } + + if (sfuService) { + try { + await sfuService.startSession({ + streamSessionId: updated.id, + ownerUserId: updated.ownerUserId, + cameraDeviceId: updated.cameraDeviceId, + requesterDeviceId: updated.requesterDeviceId, + }); + await sfuService.setSessionState(updated.id, 'live'); + } catch (error) { + console.error('Failed starting SFU session', error); + res.status(500).json({ message: 'Failed to initialize SFU session' }); + return; + } + } + + const startedPayload = createStreamStartedPayload(updated); + console.info('[stream.accept]', { + streamSessionId: updated.id, + requesterDeviceId: updated.requesterDeviceId, + cameraDeviceId: updated.cameraDeviceId, + mode: mediaSession ? 'legacy' : 'simple', + }); + const deliveredToRequester = sendRealtimeToDevice(session.requesterDeviceId, 'stream:started', startedPayload); + + if (!deliveredToRequester) { + await enqueuePushNotification({ + ownerUserId: session.ownerUserId, + recipientDeviceId: session.requesterDeviceId, + type: 'stream_started', + payload: startedPayload, + }); + } + + res.json({ message: 'Stream accepted', streamSession: toSimpleStreamSessionResponse(updated) }); + + await writeAuditLog({ + ownerUserId: session.ownerUserId, + actorDeviceId: session.cameraDeviceId, + action: 'stream.accepted', + targetType: 'stream_session', + targetId: session.id, + metadata: mediaSession + ? { mediaSessionId: updated.mediaSessionId, mediaProvider: updated.mediaProvider } + : { transport: 'webrtc' }, + ipAddress: req.ip, + }); +}); +``` + +--- + +## Snippet 7: WebRTC Signal Relay + +**What this shows:** +This Socket.IO handler validates each signal packet against the owning account and active stream session before relaying SDP or ICE data to the target device room. + +**Source:** [Backend/realtime/gateway.ts](/home/matiss/Documents/Final%20Year%20Project/Backend/realtime/gateway.ts:323) + +```ts +socket.on('webrtc:signal', async (input) => { + const parsed = webrtcSignalSchema.safeParse(input); + + if (!parsed.success) { + socket.emit('error:webrtc_signal', { + message: 'Invalid WebRTC signal payload', + errors: parsed.error.flatten(), + }); + return; + } + + const session = await db.query.streamSessions.findFirst({ + where: and(eq(streamSessions.id, parsed.data.streamSessionId), eq(streamSessions.ownerUserId, auth.userId)), + }); + + if (!session) { + socket.emit('error:webrtc_signal', { message: 'Stream session not found for this account' }); + return; + } + + if (!canRelayWebrtcSignal(session, auth.deviceId, parsed.data.toDeviceId)) { + socket.emit('error:webrtc_signal', { message: 'Signal target is not a participant in this stream session' }); + return; + } + + console.info('[stream.signal]', { + streamSessionId: parsed.data.streamSessionId, + fromDeviceId: auth.deviceId, + toDeviceId: parsed.data.toDeviceId, + signalType: parsed.data.signalType, + }); + + io?.to(roomForDevice(parsed.data.toDeviceId)).emit('webrtc:signal', { + fromDeviceId: auth.deviceId, + streamSessionId: parsed.data.streamSessionId, + signalType: parsed.data.signalType, + data: parsed.data.data ?? null, + }); +}); +``` + +--- + +## Snippet 8: Motion Event Creation and Fan-Out + +**What this shows:** +This route creates a motion event, looks up all active client links for the camera, attempts realtime delivery, and falls back to queued push delivery when the client is offline. + +**Source:** [Backend/routes/events.ts](/home/matiss/Documents/Final%20Year%20Project/Backend/routes/events.ts:35) + +```ts +router.post('/motion/start', requireDeviceAuth, async (req, res) => { + const parsed = startMotionSchema.safeParse(req.body ?? {}); + + if (!parsed.success) { + res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() }); + return; + } + + const deviceAuth = req.deviceAuth; + + if (!deviceAuth) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + const cameraDevice = await db.query.devices.findFirst({ + where: and(eq(devices.id, deviceAuth.deviceId), eq(devices.userId, deviceAuth.userId)), + }); + + if (!cameraDevice) { + res.status(404).json({ message: 'Device not found' }); + return; + } + + if (cameraDevice.role !== 'camera') { + res.status(403).json({ message: 'Only camera devices can start motion events' }); + return; + } + + const now = new Date(); + + const [event] = await db + .insert(events) + .values({ + userId: deviceAuth.userId, + deviceId: cameraDevice.id, + title: parsed.data.title, + triggeredBy: parsed.data.triggeredBy, + status: 'recording', + startedAt: now, + videoUrl: parsed.data.videoUrl, + updatedAt: now, + }) + .returning(); + + if (!event) { + res.status(500).json({ message: 'Failed to create motion event' }); + return; + } + + const activeLinks = await db.query.deviceLinks.findMany({ + where: and( + eq(deviceLinks.ownerUserId, deviceAuth.userId), + eq(deviceLinks.cameraDeviceId, cameraDevice.id), + eq(deviceLinks.status, 'active'), + ), + }); + + for (const link of activeLinks) { + const delivered = sendRealtimeToDevice(link.clientDeviceId, 'motion:detected', { + eventId: event.id, + cameraDeviceId: cameraDevice.id, + title: event.title, + triggeredBy: event.triggeredBy, + startedAt: event.startedAt, + }); + + await db.insert(notifications).values({ + eventId: event.id, + userId: deviceAuth.userId, + channel: delivered ? 'realtime' : 'queued', + status: delivered ? 'delivered' : 'queued', + isRead: false, + sentAt: now, + }); + + if (delivered) { + await db.insert(notificationDeliveries).values({ + ownerUserId: deviceAuth.userId, + recipientDeviceId: link.clientDeviceId, + type: 'motion_detected', + payload: { + eventId: event.id, + cameraDeviceId: cameraDevice.id, + startedAt: event.startedAt.toISOString(), + }, + status: 'delivered', + attempts: 1, + sentAt: now, + nextAttemptAt: now, + updatedAt: now, + }); + } else { + await enqueuePushNotification({ + ownerUserId: deviceAuth.userId, + recipientDeviceId: link.clientDeviceId, + type: 'motion_detected', + payload: { + eventId: event.id, + cameraDeviceId: cameraDevice.id, + startedAt: event.startedAt.toISOString(), + }, + }); + } + } +}); +``` + +--- + +## Snippet 9: Recording Finalization and Download URL Issuance + +**What this shows:** +This backend code finalizes a recording after upload, marks it ready in the database, and later issues a presigned MinIO download URL to the viewer. + +**Source:** [Backend/routes/recordings.ts](/home/matiss/Documents/Final%20Year%20Project/Backend/routes/recordings.ts:64) + +```ts +router.post('/:recordingId/finalize', requireDeviceAuth, async (req, res) => { + const parsedParams = recordingParamSchema.safeParse(req.params); + + if (!parsedParams.success) { + res.status(400).json({ message: 'Invalid recordingId', errors: parsedParams.error.flatten() }); + return; + } + + const parsed = finalizeSchema.safeParse(req.body ?? {}); + + if (!parsed.success) { + res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() }); + return; + } + + const deviceAuth = req.deviceAuth; + + if (!deviceAuth) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + const recording = await db.query.recordings.findFirst({ + where: and(eq(recordings.id, parsedParams.data.recordingId), eq(recordings.ownerUserId, deviceAuth.userId)), + }); + + if (!recording) { + res.status(404).json({ message: 'Recording not found' }); + return; + } + + if (recording.cameraDeviceId !== deviceAuth.deviceId) { + res.status(403).json({ message: 'Only camera device can finalize this recording' }); + return; + } + + const now = new Date(); + const bucket = parsed.data.bucket; + const objectKey = parsed.data.objectKey; + + await ensureMinioBucket(); + + try { + await minioClient.statObject(bucket, objectKey); + } catch (error) { + if (objectKey.startsWith('sim/')) { + const placeholder = Buffer.from( + JSON.stringify({ + message: 'simulated recording placeholder', + recordingId: recording.id, + streamSessionId: recording.streamSessionId, + createdAt: now.toISOString(), + }), + 'utf8', + ); + + await minioClient.putObject(bucket, objectKey, placeholder, placeholder.byteLength, { + 'Content-Type': 'application/json', + }); + } else if (isMissingStorageObjectError(error)) { + res.status(409).json({ message: 'Recording object does not exist in storage yet' }); + return; + } else { + throw error; + } + } + + const [updated] = await db + .update(recordings) + .set({ + objectKey, + bucket, + durationSeconds: parsed.data.durationSeconds, + sizeBytes: parsed.data.sizeBytes, + status: 'ready', + availableAt: now, + updatedAt: now, + error: null, + }) + .where(eq(recordings.id, recording.id)) + .returning(); + + res.json({ message: 'Recording finalized', recording: updated }); + + await writeAuditLog({ + ownerUserId: recording.ownerUserId, + actorDeviceId: recording.cameraDeviceId, + action: 'recording.finalized', + targetType: 'recording', + targetId: recording.id, + metadata: { objectKey: parsed.data.objectKey, bucket: parsed.data.bucket }, + ipAddress: req.ip, + }); +}); + +router.get('/:recordingId/download-url', requireDeviceAuth, async (req, res) => { + const parsedParams = recordingParamSchema.safeParse(req.params); + + if (!parsedParams.success) { + res.status(400).json({ message: 'Invalid recordingId', errors: parsedParams.error.flatten() }); + return; + } + + const deviceAuth = req.deviceAuth; + + if (!deviceAuth) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + const recording = await db.query.recordings.findFirst({ + where: and(eq(recordings.id, parsedParams.data.recordingId), eq(recordings.ownerUserId, deviceAuth.userId)), + }); + + if (!recording) { + res.status(404).json({ message: 'Recording not found' }); + return; + } + + if (recording.status !== 'ready' || !recording.objectKey || !recording.bucket) { + res.status(409).json({ message: 'Recording is not available yet' }); + return; + } + + try { + await minioClient.statObject(recording.bucket, recording.objectKey); + } catch (error) { + if (isMissingStorageObjectError(error)) { + res.status(409).json({ message: 'Recording file is missing from storage' }); + return; + } + + throw error; + } + + const downloadUrl = await minioClient.presignedGetObject( + recording.bucket, + recording.objectKey, + minioPresignedExpirySeconds, + ); + + res.json({ + recordingId: recording.id, + objectKey: recording.objectKey, + bucket: recording.bucket, + downloadUrl, + expiresInSeconds: minioPresignedExpirySeconds, + }); +}); +``` + +--- + +## Snippet 10: Web App State Model + +**What this shows:** +This is the client-side state shape for the browser app. It tracks authentication, registered device identity, live stream state, motion activity, notifications, and UI modal state. + +**Source:** [WebApp/src/lib/app/store.js](/home/matiss/Documents/Final%20Year%20Project/WebApp/src/lib/app/store.js:5) + +```js +export const createInitialState = () => ({ + page: 'auth', + session: null, + device: null, + deviceToken: null, + socketConnected: false, + isMotionActive: false, + activeMotionSource: null, + cameraStatus: 'idle', + cameraPreviewReady: false, + cameraInputDevices: [], + selectedCameraInputId: '', + linkedCameras: [], + recordings: [], + motionNotifications: [], + activeCameraDeviceId: null, + activeStreamSessionId: null, + openLinkedCameraMenuId: null, + activityLog: [], + cameraSessions: {}, + connectedStreamSessionIds: [], + loading: true, + isRegistering: false, + authForm: { + email: '', + password: '', + name: '' + }, + onboardingForm: { + name: '', + role: 'client', + pushToken: '' + }, + toasts: [], + recordingModal: { + open: false, + title: 'Recording Playback', + url: '' + }, + motionDetection: { + enabled: false, + profile: 'balanced', + state: 'idle', + score: 0, + debug: false, + lastTriggeredAt: null + }, + clientStreamMode: 'none', + clientPlaceholderText: 'Select a camera to view', + lastError: null +}); +``` + +--- + +## Snippet 11: WebRTC Offer Creation in the Web App + +**What this shows:** +This frontend controller method creates an SDP offer on the camera side and sends it through the backend Socket.IO relay to the client device. + +**Source:** [WebApp/src/lib/app/controller.js](/home/matiss/Documents/Final%20Year%20Project/WebApp/src/lib/app/controller.js:1159) + +```js +const startOfferToClient = async (streamSessionId, requesterDeviceId) => { + if (!socket) return; + + const connection = await ensurePeerConnection({ + streamSessionId, + targetDeviceId: requesterDeviceId, + asCamera: true + }); + + const offer = await connection.createOffer(); + await connection.setLocalDescription(offer); + socket.emit('webrtc:signal', { + toDeviceId: requesterDeviceId, + streamSessionId, + signalType: 'offer', + data: offer + }); +}; +``` + +--- + +## Snippet 12: Recording Finalization from the Web App + +**What this shows:** +This frontend code polls for the `awaiting_upload` recording row, uploads the locally captured WebM file to object storage, and then calls the backend finalize endpoint. + +**Source:** [WebApp/src/lib/app/controller.js](/home/matiss/Documents/Final%20Year%20Project/WebApp/src/lib/app/controller.js:1178) + +```js +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const finalizeRecordingForStream = async (streamSessionId, captureResult) => { + const currentDevice = getAppState().device; + if (!currentDevice?.id) { + addActivity('Recording', 'No device identity for finalize'); + return false; + } + + for (let attempt = 0; attempt < 8; attempt += 1) { + const recs = await api.ops.listRecordings().catch(() => ({ recordings: [] })); + const recording = (recs.recordings || []).find( + (rec) => rec.streamSessionId === streamSessionId && rec.status === 'awaiting_upload' + ); + + if (recording?.id) { + try { + if (!captureResult?.blob || captureResult.blob.size === 0) { + throw new Error('No captured video blob to upload'); + } + const compressedBlob = await compressRecordingBlob(captureResult.blob); + + const uploadMeta = await api.request('/videos/upload-url', { + method: 'POST', + body: JSON.stringify({ + fileName: `stream-${streamSessionId}.webm`, + deviceId: currentDevice.id, + prefix: 'recordings', + recordingId: recording.id + }) + }); + + const uploadResponse = await fetch(uploadMeta.uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': compressedBlob.type || 'video/webm' }, + body: compressedBlob + }); + + if (!uploadResponse.ok) { + throw new Error(`Upload failed with status ${uploadResponse.status}`); + } + + await api.events.finalizeRecording(recording.id, { + objectKey: uploadMeta.objectKey, + bucket: uploadMeta.bucket, + durationSeconds: captureResult.durationSeconds, + sizeBytes: compressedBlob.size + }); + + addActivity('Recording', 'Recording uploaded and finalized'); + return true; + } catch (error) { + console.error('Recording upload failed, falling back to simulated key', error); + const fallbackObjectKey = `sim/${streamSessionId}/${Date.now()}.webm`; + await api.events.finalizeRecording(recording.id, { + objectKey: fallbackObjectKey, + durationSeconds: captureResult?.durationSeconds ?? 15, + sizeBytes: captureResult?.blob?.size ?? 5000000 + }); + addActivity('Recording', 'Upload failed; finalized with simulator fallback'); + return true; + } + } + + await sleep(350); + } + + addActivity('Recording', 'No recording row found to finalize'); + return false; +}; +``` + +--- + +## Snippet 13: Motion Event Lifecycle in the Web App + +**What this shows:** +This controller code starts a motion event, begins local recording, and later ends the motion event while either closing a stream or uploading a standalone motion clip. + +**Source:** [WebApp/src/lib/app/controller.js](/home/matiss/Documents/Final%20Year%20Project/WebApp/src/lib/app/controller.js:1299) + +```js +const getMotionStartPayload = (source = 'manual') => + source === 'auto' + ? { title: 'Automatic Motion', triggeredBy: 'auto_motion' } + : { title: 'Simulated Motion', triggeredBy: 'motion' }; + +const startMotionEvent = async ({ source = 'manual' } = {}) => { + if (getAppState().isMotionActive || lastMotionEventId) { + return false; + } + + const response = await api.events.startMotion(getMotionStartPayload(source)); + await startCameraPreview(); + await startLocalRecording(); + lastMotionEventId = response.event.id; + + const startedAt = new Date().toISOString(); + setAppState({ + isMotionActive: true, + activeMotionSource: source + }); + + if (source === 'auto') { + updateMotionDetectionRuntime({ lastTriggeredAt: startedAt }); + } + + pushToast(source === 'auto' ? 'Automatic motion event started' : 'Motion Event Started', 'success'); + addActivity( + 'Motion', + source === 'auto' ? `Automatic motion event started (${response.event.id})` : `Started event ${response.event.id}` + ); + return true; +}; + +const endMotionEvent = async ({ source = 'manual' } = {}) => { + if (!lastMotionEventId) { + return false; + } + + const eventId = lastMotionEventId; + const streamSessionId = activeRecordingStreamSessionId; + + if (streamSessionId) { + await api.streams.end(streamSessionId); + addActivity('Stream', `Ended stream ${streamSessionId}`); + } else if (activeMediaRecorder?.state === 'recording') { + const captureResult = await stopLocalRecording(); + await uploadStandaloneMotionRecording(captureResult); + } + + await api.events.endMotion(eventId); + lastMotionEventId = null; + setAppState({ + isMotionActive: false, + activeMotionSource: null + }); + + pushToast(source === 'auto' ? 'Automatic motion event ended' : 'Motion Ended', 'success'); + addActivity( + 'Motion', + source === 'auto' ? `Automatic motion event ended (${eventId})` : `Ended event ${eventId}` + ); + return true; +}; + +const syncAutoMotionLifecycle = async ({ activeMotion }) => { + if (autoMotionTransitionInFlight) { + return; + } + + if (activeMotion) { + if (getAppState().isMotionActive || lastMotionEventId) { + return; + } + + autoMotionTransitionInFlight = true; + try { + await startMotionEvent({ source: 'auto' }); + } catch (error) { + console.error('Failed to auto-start motion event', error); + pushToast(error.message || 'Failed to start automatic motion event', 'error'); + } finally { + autoMotionTransitionInFlight = false; + } + return; + } + + if (!isAutoMotionEventActive()) { + return; + } + + autoMotionTransitionInFlight = true; + try { + await endMotionEvent({ source: 'auto' }); + } catch (error) { + console.error('Failed to auto-end motion event', error); + pushToast(error.message || 'Failed to end automatic motion event', 'error'); + } finally { + autoMotionTransitionInFlight = false; + } +}; +``` diff --git a/docs/temp-section-5-3-diagrams/5.3.1-server-bootstrap.md b/docs/temp-section-5-3-diagrams/5.3.1-server-bootstrap.md new file mode 100644 index 0000000..3a259c0 --- /dev/null +++ b/docs/temp-section-5-3-diagrams/5.3.1-server-bootstrap.md @@ -0,0 +1,29 @@ +# 5.3.1 Server Bootstrap and Runtime Setup + +This diagram shows how the backend starts, mounts services, and becomes ready to handle API and realtime traffic. + +```mermaid +sequenceDiagram + autonumber + participant Node as Node Runtime + participant Index as Backend/index.ts + participant App as Express App + participant Auth as Better Auth + participant Routes as Route Modules + participant MinIO as MinIO + participant RT as Socket.IO Gateway + participant Rec as Recordings Worker + participant Push as Push Worker + + Node->>Index: start process + Index->>App: create express app + Index->>App: configure helmet + cors + JSON middleware + Index->>Auth: mount /api/auth/* + Index->>Routes: mount videos/admin/devices/links/streams/events/recordings/ops + Index->>MinIO: ensureMinioBucket() + MinIO-->>Index: bucket ready + Index->>RT: setupRealtimeGateway(server) + Index->>Rec: startRecordingsWorker() + Index->>Push: startPushWorker() + Index->>App: listen on configured port +``` diff --git a/docs/temp-section-5-3-diagrams/5.3.10-operational-and-support-assets.md b/docs/temp-section-5-3-diagrams/5.3.10-operational-and-support-assets.md new file mode 100644 index 0000000..ffd32aa --- /dev/null +++ b/docs/temp-section-5-3-diagrams/5.3.10-operational-and-support-assets.md @@ -0,0 +1,29 @@ +# 5.3.10 Operational Documentation and Support Assets + +This diagram maps the implementation code to the support assets used to inspect, document, and validate the system. + +```mermaid +flowchart TD + Runtime[Runtime System] + APIs[API Routes] + Docs[OpenAPI Docs] + Sim[Simulator Pages] + Workers[Background Workers] + Validation[Validation and rollout docs] + Admin[Admin / Ops routes] + + Runtime --> APIs + Runtime --> Workers + Runtime --> Admin + APIs --> Docs + APIs --> Sim + Runtime --> Validation + + subgraph Support["Support layer"] + Docs + Sim + Workers + Validation + Admin + end +``` diff --git a/docs/temp-section-5-3-diagrams/5.3.2-authentication-and-sessions.md b/docs/temp-section-5-3-diagrams/5.3.2-authentication-and-sessions.md new file mode 100644 index 0000000..fb4aa06 --- /dev/null +++ b/docs/temp-section-5-3-diagrams/5.3.2-authentication-and-sessions.md @@ -0,0 +1,30 @@ +# 5.3.2 User Authentication and Session Handling + +This diagram separates human user authentication from device-level authentication. + +```mermaid +flowchart LR + User[User in Browser] + AuthAPI[/Better Auth Endpoints/] + Session[(session table)] + Users[(users table)] + Accounts[(account table)] + DeviceReg[/Device Registration API/] + DeviceToken[Signed Device Token] + DeviceAPI[/Device Auth Routes/] + + User -->|sign up / sign in| AuthAPI + AuthAPI --> Users + AuthAPI --> Accounts + AuthAPI --> Session + Session -->|cookie-backed session| User + + User -->|authenticated session| DeviceReg + DeviceReg -->|register browser as camera/client| DeviceToken + DeviceToken --> DeviceAPI + + classDef auth fill:#e8f1ff,stroke:#2563eb,stroke-width:2px,color:#111827; + classDef data fill:#fff7e8,stroke:#d97706,stroke-width:2px,color:#111827; + class AuthAPI,DeviceReg,DeviceAPI,DeviceToken auth; + class Session,Users,Accounts data; +``` diff --git a/docs/temp-section-5-3-diagrams/5.3.3-device-registration-and-presence.md b/docs/temp-section-5-3-diagrams/5.3.3-device-registration-and-presence.md new file mode 100644 index 0000000..7a04fbb --- /dev/null +++ b/docs/temp-section-5-3-diagrams/5.3.3-device-registration-and-presence.md @@ -0,0 +1,26 @@ +# 5.3.3 Device Identity Registration and Presence + +This diagram shows how a signed-in user registers a browser as a device and how presence is maintained after realtime connection. + +```mermaid +sequenceDiagram + autonumber + participant User as Signed-in User + participant Web as WebApp Controller + participant Devices as POST /devices/register + participant DB as devices + device_links + participant Token as Device Token + participant Socket as Socket.IO Gateway + + User->>Web: submit onboarding role + name + Web->>Devices: register device + Devices->>DB: insert devices row + Devices->>DB: auto-link to opposite-role devices if present + Devices-->>Web: return device + deviceToken + Web->>Web: persist saved device record + Web->>Socket: connect with device token + Socket->>DB: mark device online + Web->>Socket: periodic heartbeat + Socket->>DB: update lastSeenAt and status + Socket-->>Web: connection + heartbeat acknowledgements +``` diff --git a/docs/temp-section-5-3-diagrams/5.3.4-database-schema-and-persistence.md b/docs/temp-section-5-3-diagrams/5.3.4-database-schema-and-persistence.md new file mode 100644 index 0000000..49323ae --- /dev/null +++ b/docs/temp-section-5-3-diagrams/5.3.4-database-schema-and-persistence.md @@ -0,0 +1,97 @@ +# 5.3.4 Database Schema and Persistence Model + +## Core Entity Relationship Diagram + +```mermaid +erDiagram + USERS ||--o{ DEVICES : owns + USERS ||--o{ DEVICE_LINKS : owns + DEVICES ||--o{ DEVICE_LINKS : cameraDeviceId + DEVICES ||--o{ DEVICE_LINKS : clientDeviceId + USERS ||--o{ STREAM_SESSIONS : owns + DEVICES ||--o{ STREAM_SESSIONS : cameraDeviceId + DEVICES ||--o{ STREAM_SESSIONS : requesterDeviceId + + USERS { + uuid id PK + varchar email + varchar name + } + DEVICES { + uuid id PK + uuid user_id FK + varchar role + varchar status + timestamp last_seen_at + } + DEVICE_LINKS { + uuid id PK + uuid owner_user_id FK + uuid camera_device_id FK + uuid client_device_id FK + varchar status + } + STREAM_SESSIONS { + uuid id PK + uuid owner_user_id FK + uuid camera_device_id FK + uuid requester_device_id FK + varchar status + varchar reason + } +``` + +## Media and Event Persistence + +```mermaid +erDiagram + USERS ||--o{ EVENTS : owns + DEVICES ||--o{ EVENTS : triggeredBy + STREAM_SESSIONS ||--o{ RECORDINGS : produces + EVENTS ||--o{ RECORDINGS : mayCreate + USERS ||--o{ NOTIFICATIONS : receives + EVENTS ||--o{ NOTIFICATIONS : generates + USERS ||--o{ NOTIFICATION_DELIVERIES : owns + DEVICES ||--o{ NOTIFICATION_DELIVERIES : targets + USERS ||--o{ AUDIT_LOGS : owns + DEVICES ||--o{ AUDIT_LOGS : actor + + EVENTS { + uuid id PK + uuid user_id FK + uuid device_id FK + varchar status + timestamp started_at + timestamp ended_at + } + RECORDINGS { + uuid id PK + uuid stream_session_id FK + uuid event_id FK + varchar object_key + varchar bucket + varchar status + } + NOTIFICATIONS { + uuid id PK + uuid event_id FK + uuid user_id FK + varchar channel + varchar status + } + NOTIFICATION_DELIVERIES { + uuid id PK + uuid recipient_device_id FK + varchar type + varchar status + int attempts + } + AUDIT_LOGS { + uuid id PK + uuid owner_user_id FK + uuid actor_device_id FK + varchar action + varchar target_type + varchar target_id + } +``` diff --git a/docs/temp-section-5-3-diagrams/5.3.5-linking-and-control-flow.md b/docs/temp-section-5-3-diagrams/5.3.5-linking-and-control-flow.md new file mode 100644 index 0000000..1aca9a1 --- /dev/null +++ b/docs/temp-section-5-3-diagrams/5.3.5-linking-and-control-flow.md @@ -0,0 +1,36 @@ +# 5.3.5 Device Linking and Command-Oriented Control + +This comparison diagram contrasts the older command-driven path with the current simplified stream request path. + +```mermaid +flowchart LR + subgraph Legacy["Legacy command-oriented path"] + LClient[Client Device] + LBackend[Backend] + LCmd[(commands table)] + LGateway[Realtime Gateway] + LCamera[Camera Device] + + LClient -->|request stream| LBackend + LBackend --> LCmd + LCmd --> LGateway + LGateway -->|command:received| LCamera + LCamera -->|command:ack| LGateway + LGateway --> LBackend + end + + subgraph Simple["Simplified linked-device path"] + SClient[Client Device] + SBackend[Backend] + SLinks[(device_links)] + SSession[(stream_sessions)] + SCamera[Camera Device] + + SClient -->|request stream| SBackend + SBackend --> SLinks + SBackend --> SSession + SBackend -->|stream:requested| SCamera + SCamera -->|accept| SBackend + SBackend -->|stream:started| SClient + end +``` diff --git a/docs/temp-section-5-3-diagrams/5.3.6-stream-sessions-and-signalling.md b/docs/temp-section-5-3-diagrams/5.3.6-stream-sessions-and-signalling.md new file mode 100644 index 0000000..d4fbfe4 --- /dev/null +++ b/docs/temp-section-5-3-diagrams/5.3.6-stream-sessions-and-signalling.md @@ -0,0 +1,46 @@ +# 5.3.6 Stream Sessions and Signalling + +## Stream Request and WebRTC Signalling Sequence + +```mermaid +sequenceDiagram + autonumber + participant Client as Client Browser + participant Backend as Backend /streams + participant Links as device_links + participant Session as stream_sessions + participant Camera as Camera Browser + participant RT as Socket.IO Relay + + Client->>Backend: POST /streams/request + Backend->>Links: verify active camera-client link + Backend->>Session: insert requested session + Backend->>Camera: emit stream:requested + Camera->>Backend: POST /streams/:id/accept + Backend->>Session: update status to streaming + Backend->>Client: emit stream:started + Camera->>RT: webrtc:signal offer + RT->>Client: relay offer + Client->>RT: webrtc:signal answer + RT->>Camera: relay answer + Client->>RT: ICE candidates + RT->>Camera: relay ICE candidates + Camera->>RT: ICE candidates + RT->>Client: relay ICE candidates +``` + +## Stream Session State Diagram + +```mermaid +stateDiagram-v2 + [*] --> requested + requested --> streaming: camera accepts + requested --> cancelled: requester cancels + requested --> failed: validation or delivery failure + streaming --> completed: normal end + streaming --> cancelled: manual stop + streaming --> failed: connection or upload failure + completed --> [*] + cancelled --> [*] + failed --> [*] +``` diff --git a/docs/temp-section-5-3-diagrams/5.3.7-motion-events-notifications-audit.md b/docs/temp-section-5-3-diagrams/5.3.7-motion-events-notifications-audit.md new file mode 100644 index 0000000..cf99b10 --- /dev/null +++ b/docs/temp-section-5-3-diagrams/5.3.7-motion-events-notifications-audit.md @@ -0,0 +1,42 @@ +# 5.3.7 Motion Events Notifications and Audit Trail + +## Motion Event Lifecycle + +```mermaid +flowchart TD + Trigger[Manual trigger or browser motion detector] + Start[/POST /events/motion/start/] + Event[(events table)] + Links[(device_links)] + RT[Realtime motion:detected] + Push[Queued push notification] + End[/POST /events/:id/motion/end/] + + Trigger --> Start + Start --> Event + Start --> Links + Links --> RT + Links --> Push + RT --> End + Push --> End + End --> Event +``` + +## Activity and Audit Flow + +```mermaid +flowchart LR + Camera[Camera Device] + Event[(events)] + Notif[(notifications)] + Delivery[(notification_deliveries)] + Audit[(audit_logs)] + Client[Client Device] + + Camera --> Event + Event --> Notif + Event --> Delivery + Event --> Audit + Notif --> Client + Delivery --> Client +``` diff --git a/docs/temp-section-5-3-diagrams/5.3.8-recordings-and-object-storage.md b/docs/temp-section-5-3-diagrams/5.3.8-recordings-and-object-storage.md new file mode 100644 index 0000000..29ba9ba --- /dev/null +++ b/docs/temp-section-5-3-diagrams/5.3.8-recordings-and-object-storage.md @@ -0,0 +1,40 @@ +# 5.3.8 Recordings and Object Storage + +## Recording Pipeline + +```mermaid +sequenceDiagram + autonumber + participant Cam as Camera Browser + participant Ctrl as Web Controller + participant Videos as /videos/upload-url + participant MinIO as MinIO Object Storage + participant Rec as /recordings/:id/finalize + participant DB as recordings table + participant Viewer as Client Browser + + Cam->>Ctrl: stop local MediaRecorder + Ctrl->>Ctrl: compress recording blob + Ctrl->>Videos: request presigned upload URL + Videos-->>Ctrl: objectKey + uploadUrl + Ctrl->>MinIO: PUT recording blob + Ctrl->>Rec: finalize recording metadata + Rec->>DB: mark recording ready + Viewer->>Rec: GET /recordings/:id/download-url + Rec-->>Viewer: presigned download URL +``` + +## Storage Architecture + +```mermaid +flowchart LR + Backend[Backend Service] + PG[(Postgres)] + MinIO[(MinIO / S3)] + Web[Web Client] + + Web -->|stream, event, recording metadata| Backend + Backend -->|users, devices, links, sessions, events, recordings| PG + Backend -->|presigned upload/download + object checks| MinIO + Web -->|PUT / GET via presigned URLs| MinIO +``` diff --git a/docs/temp-section-5-3-diagrams/5.3.9-web-application-implementation.md b/docs/temp-section-5-3-diagrams/5.3.9-web-application-implementation.md new file mode 100644 index 0000000..1416f55 --- /dev/null +++ b/docs/temp-section-5-3-diagrams/5.3.9-web-application-implementation.md @@ -0,0 +1,53 @@ +# 5.3.9 Web Application Implementation + +## Web Application Architecture + +```mermaid +flowchart LR + Routes[Svelte Route Pages] + Controller[controller.js] + Store[store.js] + API[api.js] + Socket[Socket.IO Client] + Backend[Backend APIs] + + Routes --> Controller + Controller --> Store + Controller --> API + Controller --> Socket + API --> Backend + Socket --> Backend + + subgraph RoutePages["Main route pages"] + Auth[Auth] + Onboarding[Onboarding] + Camera[Camera] + Client[Client] + Activity[Activity] + Settings[Settings] + end + + Routes --> RoutePages +``` + +## UI Flow + +```mermaid +flowchart TD + Start[Open WebApp] + Auth[Sign in / Sign up] + Onboarding[Register browser as device] + Role{Chosen role} + Camera[Camera dashboard] + Client[Client dashboard] + Activity[Activity page] + Settings[Settings page] + + Start --> Auth --> Onboarding --> Role + Role -->|camera| Camera + Role -->|client| Client + Camera --> Activity + Camera --> Settings + Client --> Activity + Client --> Settings +``` diff --git a/docs/temp-section-5-3-diagrams/README.md b/docs/temp-section-5-3-diagrams/README.md new file mode 100644 index 0000000..91c5f59 --- /dev/null +++ b/docs/temp-section-5-3-diagrams/README.md @@ -0,0 +1,18 @@ +# Temporary Section 5.3 Diagrams + +These temporary files contain Mermaid diagrams for the `5.3` implementation chapter. + +## Files + +- [5.3.1 Server Bootstrap](./5.3.1-server-bootstrap.md) +- [5.3.2 Authentication and Sessions](./5.3.2-authentication-and-sessions.md) +- [5.3.3 Device Registration and Presence](./5.3.3-device-registration-and-presence.md) +- [5.3.4 Database Schema and Persistence](./5.3.4-database-schema-and-persistence.md) +- [5.3.5 Linking and Control Flow](./5.3.5-linking-and-control-flow.md) +- [5.3.6 Stream Sessions and Signalling](./5.3.6-stream-sessions-and-signalling.md) +- [5.3.7 Motion Events Notifications Audit](./5.3.7-motion-events-notifications-audit.md) +- [5.3.8 Recordings and Object Storage](./5.3.8-recordings-and-object-storage.md) +- [5.3.9 Web Application Implementation](./5.3.9-web-application-implementation.md) +- [5.3.10 Operational and Support Assets](./5.3.10-operational-and-support-assets.md) + +These are temporary working diagrams intended for screenshots and report drafting.