feat(camera): add camera management functionality and data flow documentation
- Implemented camera slice in Redux store for managing camera state. - Added new tables in PostgreSQL for camera devices, lending, and audit logs. - Created camera-related schemas in contracts for validation. - Developed camera data flow documentation outlining ingest and read processes. - Introduced async thunks for fetching, creating, lending, returning, and updating cameras.
This commit is contained in:
1 parent
7923ba1b8d
commit
d3b6c5032f
11 files changed
+2524
-114
No files matched your search
@@ -35,4 +35,114 @@ describe('ingest-api', () => {
|
||||
it('rejects invalid telemetry', async () => {
|
||||
await request(app.callback()).post('/telemetry').send({ deviceId: '' }).expect(400);
|
||||
});
|
||||
|
||||
it('lists simulated cameras', async () => {
|
||||
const response = await request(app.callback()).get('/cameras').expect(200);
|
||||
expect(response.body.items).toHaveLength(3);
|
||||
expect(response.body.items[0]?.cameraId).toBe('cam-lobby-01');
|
||||
expect(response.body.items[0]?.model).toBeNull();
|
||||
expect(response.body.items[0]?.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('creates a new camera device and blocks duplicates', async () => {
|
||||
const created = await request(app.callback())
|
||||
.post('/cameras')
|
||||
.send({
|
||||
cameraId: 'cam-new-77',
|
||||
name: 'New Camera',
|
||||
location: 'Dock 4',
|
||||
model: 'Model-Z',
|
||||
notes: 'Installed this week',
|
||||
isActive: true,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(created.body.cameraId).toBe('cam-new-77');
|
||||
expect(created.body.name).toBe('New Camera');
|
||||
|
||||
await request(app.callback())
|
||||
.post('/cameras')
|
||||
.send({
|
||||
cameraId: 'cam-new-77',
|
||||
name: 'Duplicate',
|
||||
location: 'Dock 5',
|
||||
})
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
it('simulates camera ingest and exposes latest status', async () => {
|
||||
writes.length = 0;
|
||||
|
||||
const simulate = await request(app.callback())
|
||||
.post('/cameras/cam-lobby-01/simulate-ingest')
|
||||
.send({})
|
||||
.expect(200);
|
||||
|
||||
expect(simulate.body.accepted).toBe(true);
|
||||
expect(simulate.body.latest.health).toBe('online');
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0]?.deviceId).toBe('cam-lobby-01');
|
||||
|
||||
const latest = await request(app.callback()).get('/cameras/cam-lobby-01/latest').expect(200);
|
||||
expect(latest.body.cameraId).toBe('cam-lobby-01');
|
||||
expect(latest.body.payload).not.toBeNull();
|
||||
});
|
||||
|
||||
it('accepts lend form and prevents double lend', async () => {
|
||||
const dueDate = new Date(Date.now() + 86_400_000).toISOString();
|
||||
|
||||
const first = await request(app.callback())
|
||||
.post('/cameras/cam-warehouse-02/lend')
|
||||
.send({ borrower: 'Marco', dueDate, purpose: 'Site audit' })
|
||||
.expect(200);
|
||||
|
||||
expect(first.body.lend.borrower).toBe('Marco');
|
||||
|
||||
await request(app.callback())
|
||||
.post('/cameras/cam-warehouse-02/lend')
|
||||
.send({ borrower: 'Another', dueDate })
|
||||
.expect(409);
|
||||
|
||||
const list = await request(app.callback()).get('/cameras').expect(200);
|
||||
const lent = list.body.items.find((item: { cameraId: string }) => item.cameraId === 'cam-warehouse-02');
|
||||
expect(lent?.lend?.borrower).toBe('Marco');
|
||||
|
||||
const returned = await request(app.callback())
|
||||
.post('/cameras/cam-warehouse-02/return')
|
||||
.set('x-actor', 'qa-user')
|
||||
.send({})
|
||||
.expect(200);
|
||||
expect(returned.body.returned).toBe(true);
|
||||
|
||||
await request(app.callback()).post('/cameras/cam-warehouse-02/return').send({}).expect(409);
|
||||
});
|
||||
|
||||
it('updates camera metadata from device catalog', async () => {
|
||||
const update = await request(app.callback())
|
||||
.patch('/cameras/cam-parking-03')
|
||||
.send({ name: 'Gate Cam', model: 'Axis-M11', notes: 'North gate', isActive: false })
|
||||
.expect(200);
|
||||
|
||||
expect(update.body.name).toBe('Gate Cam');
|
||||
expect(update.body.model).toBe('Axis-M11');
|
||||
expect(update.body.isActive).toBe(false);
|
||||
|
||||
const list = await request(app.callback()).get('/cameras').expect(200);
|
||||
const updated = list.body.items.find((item: { cameraId: string }) => item.cameraId === 'cam-parking-03');
|
||||
expect(updated?.name).toBe('Gate Cam');
|
||||
expect(updated?.notes).toBe('North gate');
|
||||
expect(updated?.isActive).toBe(false);
|
||||
|
||||
const audit = await request(app.callback()).get('/cameras/cam-parking-03/audit').expect(200);
|
||||
expect(audit.body.items.length).toBeGreaterThan(0);
|
||||
expect(audit.body.items[0].action).toBe('camera_deactivated');
|
||||
expect(audit.body.items[0].actor).toBe('system-demo');
|
||||
|
||||
await request(app.callback())
|
||||
.patch('/cameras/cam-parking-03')
|
||||
.send({ isActive: true })
|
||||
.expect(200);
|
||||
const restoredAudit = await request(app.callback()).get('/cameras/cam-parking-03/audit').expect(200);
|
||||
expect(restoredAudit.body.items[0].action).toBe('camera_activated');
|
||||
});
|
||||
});
|
||||
+438
-3
@@ -1,13 +1,142 @@
|
||||
import cors from '@koa/cors';
|
||||
import Router from '@koa/router';
|
||||
import { telemetryIngestSchema } from 'contracts';
|
||||
import {
|
||||
cameraAuditLogListSchema,
|
||||
cameraCreateRequestSchema,
|
||||
cameraDeviceUpdateSchema,
|
||||
cameraLendRequestSchema,
|
||||
cameraListResponseSchema,
|
||||
cameraReturnResponseSchema,
|
||||
cameraSimulateIngestResponseSchema,
|
||||
telemetryIngestSchema,
|
||||
type CameraLatestStatus,
|
||||
type CameraLendInfo,
|
||||
type CameraRuntimePayload,
|
||||
} from 'contracts';
|
||||
import Koa from 'koa';
|
||||
import { koaBody } from 'koa-body';
|
||||
import { createTelemetryWriter, type TelemetryWriter } from './stores.js';
|
||||
|
||||
type CameraSeed = {
|
||||
cameraId: string;
|
||||
name: string;
|
||||
location: string;
|
||||
};
|
||||
|
||||
type CameraState = CameraSeed & {
|
||||
latest: CameraLatestStatus;
|
||||
lend: CameraLendInfo | null;
|
||||
};
|
||||
|
||||
const cameraSeeds: CameraSeed[] = [
|
||||
{ cameraId: 'cam-lobby-01', name: 'Lobby Entrance', location: 'HQ Lobby' },
|
||||
{ cameraId: 'cam-warehouse-02', name: 'Warehouse Aisle', location: 'Warehouse 1' },
|
||||
{ cameraId: 'cam-parking-03', name: 'Parking Gate', location: 'Main Gate' },
|
||||
];
|
||||
|
||||
function randomPayload(): CameraRuntimePayload {
|
||||
const scenes = ['quiet', 'normal', 'crowded'];
|
||||
return {
|
||||
fps: 20 + Math.round(Math.random() * 10),
|
||||
bitrateKbps: 1500 + Math.round(Math.random() * 2500),
|
||||
recording: Math.random() > 0.08,
|
||||
scene: scenes[Math.floor(Math.random() * scenes.length)] ?? 'normal',
|
||||
};
|
||||
}
|
||||
|
||||
function createCameraStates(): Map<string, CameraState> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function ensureCameraState(cameras: Map<string, CameraState>, seed: CameraSeed): CameraState {
|
||||
const existing = cameras.get(seed.cameraId);
|
||||
if (existing) {
|
||||
existing.name = seed.name;
|
||||
existing.location = seed.location;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created: CameraState = {
|
||||
...seed,
|
||||
latest: {
|
||||
cameraId: seed.cameraId,
|
||||
lastSeen: null,
|
||||
health: 'unknown',
|
||||
payload: null,
|
||||
},
|
||||
lend: null,
|
||||
};
|
||||
cameras.set(seed.cameraId, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function ingestCamera(camera: CameraState, writer: TelemetryWriter) {
|
||||
const sentAt = new Date().toISOString();
|
||||
const payload = randomPayload();
|
||||
const writes = await writer.write({
|
||||
deviceId: camera.cameraId,
|
||||
sentAt,
|
||||
payload: {
|
||||
source: 'camera-simulator',
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
|
||||
camera.latest = {
|
||||
cameraId: camera.cameraId,
|
||||
lastSeen: sentAt,
|
||||
health: 'online',
|
||||
payload,
|
||||
};
|
||||
|
||||
const accepted = writes.mongo && writes.redis && writes.postgres;
|
||||
|
||||
return {
|
||||
accepted,
|
||||
writes,
|
||||
latest: camera.latest,
|
||||
};
|
||||
}
|
||||
|
||||
export function createApp(writer: TelemetryWriter = createTelemetryWriter()): Koa {
|
||||
const app = new Koa();
|
||||
const router = new Router();
|
||||
const cameras = createCameraStates();
|
||||
let deviceSeeded: Promise<void> | null = null;
|
||||
|
||||
function ensureDeviceCatalog() {
|
||||
deviceSeeded ??= writer.ensureDevices(cameraSeeds);
|
||||
return deviceSeeded;
|
||||
}
|
||||
|
||||
function requestActor(ctx: Koa.ParameterizedContext): string {
|
||||
const actorHeader = ctx.get('x-actor').trim();
|
||||
return actorHeader.length > 0 ? actorHeader : 'system-demo';
|
||||
}
|
||||
|
||||
const autoIngestTimer = setInterval(() => {
|
||||
void (async () => {
|
||||
await ensureDeviceCatalog();
|
||||
const devices = await writer.listDevices();
|
||||
await Promise.all(
|
||||
devices.filter((device) => device.isActive).map(async (device) => {
|
||||
const camera = ensureCameraState(cameras, {
|
||||
cameraId: device.cameraId,
|
||||
name: device.name,
|
||||
location: device.location,
|
||||
});
|
||||
try {
|
||||
await ingestCamera(camera, writer);
|
||||
} catch (error) {
|
||||
console.error(`auto simulate ingest failed for ${camera.cameraId}`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
})().catch((error) => {
|
||||
console.error('auto simulate ingest tick failed', error);
|
||||
});
|
||||
}, 1000);
|
||||
autoIngestTimer.unref?.();
|
||||
|
||||
app.use(cors());
|
||||
app.use(koaBody());
|
||||
@@ -21,7 +150,7 @@ export function createApp(writer: TelemetryWriter = createTelemetryWriter()): Ko
|
||||
});
|
||||
|
||||
router.post('/telemetry', async (ctx) => {
|
||||
const parsed = telemetryIngestSchema.safeParse(ctx.request.body);
|
||||
const parsed = telemetryIngestSchema.safeParse((ctx.request as { body?: unknown }).body);
|
||||
if (!parsed.success) {
|
||||
ctx.status = 400;
|
||||
ctx.body = { error: parsed.error.flatten() };
|
||||
@@ -29,7 +158,313 @@ export function createApp(writer: TelemetryWriter = createTelemetryWriter()): Ko
|
||||
}
|
||||
|
||||
const writes = await writer.write(parsed.data);
|
||||
ctx.body = { accepted: true, writes };
|
||||
const accepted = writes.mongo && writes.redis && writes.postgres;
|
||||
if (!accepted) {
|
||||
ctx.status = 503;
|
||||
}
|
||||
ctx.body = { accepted, writes };
|
||||
});
|
||||
|
||||
router.get('/cameras', async (ctx) => {
|
||||
await ensureDeviceCatalog();
|
||||
const devices = await writer.listDevices();
|
||||
const items = await Promise.all(
|
||||
devices.map(async (device) => {
|
||||
const camera = ensureCameraState(cameras, {
|
||||
cameraId: device.cameraId,
|
||||
name: device.name,
|
||||
location: device.location,
|
||||
});
|
||||
const lend = await writer.readLend(camera.cameraId);
|
||||
camera.lend = lend;
|
||||
return {
|
||||
cameraId: camera.cameraId,
|
||||
name: camera.name,
|
||||
location: camera.location,
|
||||
model: device.model,
|
||||
notes: device.notes,
|
||||
isActive: device.isActive,
|
||||
latest: camera.latest,
|
||||
lend: camera.lend,
|
||||
};
|
||||
}),
|
||||
);
|
||||
ctx.body = cameraListResponseSchema.parse({ items });
|
||||
});
|
||||
|
||||
router.get('/cameras/:id/latest', async (ctx) => {
|
||||
await ensureDeviceCatalog();
|
||||
const device = await writer.getDevice(ctx.params.id);
|
||||
if (!device) {
|
||||
ctx.status = 404;
|
||||
ctx.body = { error: 'Camera not found' };
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = ensureCameraState(cameras, {
|
||||
cameraId: device.cameraId,
|
||||
name: device.name,
|
||||
location: device.location,
|
||||
});
|
||||
|
||||
ctx.body = camera.latest;
|
||||
});
|
||||
|
||||
router.post('/cameras', async (ctx) => {
|
||||
await ensureDeviceCatalog();
|
||||
const actor = requestActor(ctx);
|
||||
const parsed = cameraCreateRequestSchema.safeParse((ctx.request as { body?: unknown }).body);
|
||||
if (!parsed.success) {
|
||||
ctx.status = 400;
|
||||
ctx.body = { error: parsed.error.flatten() };
|
||||
return;
|
||||
}
|
||||
|
||||
const created = await writer.createDevice(parsed.data);
|
||||
if (!created) {
|
||||
ctx.status = 409;
|
||||
ctx.body = { error: 'Camera already exists' };
|
||||
return;
|
||||
}
|
||||
|
||||
await writer.logAudit(created.cameraId, 'camera_created', actor, {
|
||||
created: {
|
||||
name: created.name,
|
||||
location: created.location,
|
||||
model: created.model,
|
||||
notes: created.notes,
|
||||
isActive: created.isActive,
|
||||
},
|
||||
});
|
||||
|
||||
const camera = ensureCameraState(cameras, {
|
||||
cameraId: created.cameraId,
|
||||
name: created.name,
|
||||
location: created.location,
|
||||
});
|
||||
|
||||
ctx.status = 201;
|
||||
ctx.body = {
|
||||
cameraId: created.cameraId,
|
||||
name: created.name,
|
||||
location: created.location,
|
||||
model: created.model,
|
||||
notes: created.notes,
|
||||
isActive: created.isActive,
|
||||
latest: camera.latest,
|
||||
lend: null,
|
||||
};
|
||||
});
|
||||
|
||||
router.post('/cameras/:id/simulate-ingest', async (ctx) => {
|
||||
await ensureDeviceCatalog();
|
||||
const device = await writer.getDevice(ctx.params.id);
|
||||
if (!device) {
|
||||
ctx.status = 404;
|
||||
ctx.body = { error: 'Camera not found' };
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = ensureCameraState(cameras, {
|
||||
cameraId: device.cameraId,
|
||||
name: device.name,
|
||||
location: device.location,
|
||||
});
|
||||
|
||||
ctx.body = cameraSimulateIngestResponseSchema.parse(await ingestCamera(camera, writer));
|
||||
});
|
||||
|
||||
router.patch('/cameras/:id', async (ctx) => {
|
||||
await ensureDeviceCatalog();
|
||||
const actor = requestActor(ctx);
|
||||
const parsed = cameraDeviceUpdateSchema.safeParse((ctx.request as { body?: unknown }).body);
|
||||
if (!parsed.success) {
|
||||
ctx.status = 400;
|
||||
ctx.body = { error: parsed.error.flatten() };
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = await writer.getDevice(ctx.params.id);
|
||||
if (!previous) {
|
||||
ctx.status = 404;
|
||||
ctx.body = { error: 'Camera not found' };
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await writer.updateDevice(ctx.params.id, parsed.data);
|
||||
if (!updated) {
|
||||
ctx.status = 404;
|
||||
ctx.body = { error: 'Camera not found' };
|
||||
return;
|
||||
}
|
||||
|
||||
const deactivated = previous.isActive && updated.isActive === false;
|
||||
const activated = previous.isActive === false && updated.isActive;
|
||||
const action = deactivated
|
||||
? 'camera_deactivated'
|
||||
: activated
|
||||
? 'camera_activated'
|
||||
: 'camera_updated';
|
||||
await writer.logAudit(
|
||||
updated.cameraId,
|
||||
action,
|
||||
actor,
|
||||
{
|
||||
patch: parsed.data,
|
||||
previous: {
|
||||
name: previous.name,
|
||||
location: previous.location,
|
||||
model: previous.model,
|
||||
notes: previous.notes,
|
||||
isActive: previous.isActive,
|
||||
},
|
||||
next: {
|
||||
name: updated.name,
|
||||
location: updated.location,
|
||||
model: updated.model,
|
||||
notes: updated.notes,
|
||||
isActive: updated.isActive,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const camera = ensureCameraState(cameras, {
|
||||
cameraId: updated.cameraId,
|
||||
name: updated.name,
|
||||
location: updated.location,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
cameraId: updated.cameraId,
|
||||
name: updated.name,
|
||||
location: updated.location,
|
||||
model: updated.model,
|
||||
notes: updated.notes,
|
||||
isActive: updated.isActive,
|
||||
latest: camera.latest,
|
||||
lend: camera.lend,
|
||||
};
|
||||
});
|
||||
|
||||
router.post('/cameras/:id/lend', async (ctx) => {
|
||||
await ensureDeviceCatalog();
|
||||
const actor = requestActor(ctx);
|
||||
const device = await writer.getDevice(ctx.params.id);
|
||||
if (!device) {
|
||||
ctx.status = 404;
|
||||
ctx.body = { error: 'Camera not found' };
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = ensureCameraState(cameras, {
|
||||
cameraId: device.cameraId,
|
||||
name: device.name,
|
||||
location: device.location,
|
||||
});
|
||||
|
||||
const parsed = cameraLendRequestSchema.safeParse((ctx.request as { body?: unknown }).body);
|
||||
if (!parsed.success) {
|
||||
ctx.status = 400;
|
||||
ctx.body = { error: parsed.error.flatten() };
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await writer.readLend(camera.cameraId);
|
||||
if (existing) {
|
||||
camera.lend = existing;
|
||||
ctx.status = 409;
|
||||
ctx.body = { error: 'Camera already lent' };
|
||||
return;
|
||||
}
|
||||
|
||||
const lend = {
|
||||
borrower: parsed.data.borrower,
|
||||
dueDate: parsed.data.dueDate,
|
||||
purpose: parsed.data.purpose,
|
||||
lentAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const saved = await writer.saveLend(camera.cameraId, lend);
|
||||
if (!saved) {
|
||||
ctx.status = 409;
|
||||
ctx.body = { error: 'Camera already lent' };
|
||||
return;
|
||||
}
|
||||
|
||||
await writer.logAudit(camera.cameraId, 'camera_lent', actor, {
|
||||
borrower: lend.borrower,
|
||||
dueDate: lend.dueDate,
|
||||
purpose: lend.purpose,
|
||||
lentAt: lend.lentAt,
|
||||
});
|
||||
|
||||
camera.lend = lend;
|
||||
|
||||
ctx.body = {
|
||||
cameraId: camera.cameraId,
|
||||
name: camera.name,
|
||||
location: camera.location,
|
||||
model: device.model,
|
||||
notes: device.notes,
|
||||
isActive: device.isActive,
|
||||
latest: camera.latest,
|
||||
lend: camera.lend,
|
||||
};
|
||||
});
|
||||
|
||||
router.post('/cameras/:id/return', async (ctx) => {
|
||||
await ensureDeviceCatalog();
|
||||
const actor = requestActor(ctx);
|
||||
const device = await writer.getDevice(ctx.params.id);
|
||||
if (!device) {
|
||||
ctx.status = 404;
|
||||
ctx.body = { error: 'Camera not found' };
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLend = await writer.readLend(device.cameraId);
|
||||
if (!currentLend) {
|
||||
ctx.status = 409;
|
||||
ctx.body = { error: 'Camera is not currently lent' };
|
||||
return;
|
||||
}
|
||||
|
||||
const returned = await writer.returnLend(device.cameraId);
|
||||
if (!returned) {
|
||||
ctx.status = 500;
|
||||
ctx.body = { error: 'Failed to return camera' };
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = cameras.get(device.cameraId);
|
||||
if (camera) {
|
||||
camera.lend = null;
|
||||
}
|
||||
|
||||
await writer.logAudit(device.cameraId, 'camera_returned', actor, {
|
||||
previousLend: currentLend,
|
||||
returnedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
ctx.body = cameraReturnResponseSchema.parse({
|
||||
cameraId: device.cameraId,
|
||||
returned: true,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/cameras/:id/audit', async (ctx) => {
|
||||
await ensureDeviceCatalog();
|
||||
const device = await writer.getDevice(ctx.params.id);
|
||||
if (!device) {
|
||||
ctx.status = 404;
|
||||
ctx.body = { error: 'Camera not found' };
|
||||
return;
|
||||
}
|
||||
|
||||
const limitRaw = Number(ctx.query.limit);
|
||||
const limit = Number.isFinite(limitRaw) ? limitRaw : 20;
|
||||
const items = await writer.listAudit(device.cameraId, limit);
|
||||
ctx.body = cameraAuditLogListSchema.parse({ items });
|
||||
});
|
||||
|
||||
app.use(router.routes());
|
||||
|
||||
+665
-32
@@ -1,9 +1,35 @@
|
||||
import { PRESENCE_TTL_SECONDS, type TelemetryIngest } from 'contracts';
|
||||
import Redis from 'ioredis';
|
||||
import {
|
||||
PRESENCE_TTL_SECONDS,
|
||||
type CameraAuditAction,
|
||||
type CameraAuditLog,
|
||||
type CameraCreateRequest,
|
||||
type CameraDevice,
|
||||
type CameraDeviceUpdate,
|
||||
type TelemetryIngest,
|
||||
} from 'contracts';
|
||||
import { Redis } from 'ioredis';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import pg from 'pg';
|
||||
import { env } from './env.js';
|
||||
|
||||
type CameraLendRecord = {
|
||||
borrower: string;
|
||||
dueDate: string;
|
||||
purpose?: string;
|
||||
lentAt: string;
|
||||
};
|
||||
|
||||
type TelemetryQueueEnvelope = {
|
||||
event: TelemetryIngest;
|
||||
queuedAt: string;
|
||||
};
|
||||
|
||||
type DeviceSeed = {
|
||||
cameraId: string;
|
||||
name: string;
|
||||
location: string;
|
||||
};
|
||||
|
||||
export type WriteReport = {
|
||||
mongo: boolean;
|
||||
redis: boolean;
|
||||
@@ -11,7 +37,22 @@ export type WriteReport = {
|
||||
};
|
||||
|
||||
export type TelemetryWriter = {
|
||||
ensureDevices(seed: DeviceSeed[]): Promise<void>;
|
||||
listDevices(): Promise<CameraDevice[]>;
|
||||
getDevice(cameraId: string): Promise<CameraDevice | null>;
|
||||
createDevice(input: CameraCreateRequest): Promise<CameraDevice | null>;
|
||||
updateDevice(cameraId: string, patch: CameraDeviceUpdate): Promise<CameraDevice | null>;
|
||||
write(event: TelemetryIngest): Promise<WriteReport>;
|
||||
readLend(cameraId: string): Promise<CameraLendRecord | null>;
|
||||
saveLend(cameraId: string, lend: CameraLendRecord): Promise<boolean>;
|
||||
returnLend(cameraId: string): Promise<boolean>;
|
||||
logAudit(
|
||||
cameraId: string,
|
||||
action: CameraAuditAction,
|
||||
actor: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<void>;
|
||||
listAudit(cameraId: string, limit?: number): Promise<CameraAuditLog[]>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
@@ -22,47 +63,133 @@ function redisKey(deviceId: string): string {
|
||||
export function createTelemetryWriter(): TelemetryWriter {
|
||||
const mongo = new MongoClient(env.mongoUrl);
|
||||
const redis = new Redis(env.redisUrl, { maxRetriesPerRequest: 1, lazyConnect: true });
|
||||
const queue = new Redis(env.redisUrl, { maxRetriesPerRequest: 1, lazyConnect: true });
|
||||
const pool = new pg.Pool({ connectionString: env.databaseUrl });
|
||||
let mongoReady: Promise<MongoClient> | null = null;
|
||||
let postgresSchemaReady: Promise<void> | null = null;
|
||||
let workerReady: Promise<void> | null = null;
|
||||
let workerTask: Promise<void> | null = null;
|
||||
let stopWorker = false;
|
||||
|
||||
const streamKey = 'telemetry:stream';
|
||||
const streamDlqKey = 'telemetry:stream:dlq';
|
||||
const streamGroup = 'ingest-writers';
|
||||
const consumerName = `ingest-${process.pid}`;
|
||||
const streamMaxLen = 50_000;
|
||||
const streamDlqMaxLen = 5_000;
|
||||
|
||||
function mongoClient(): Promise<MongoClient> {
|
||||
mongoReady ??= mongo.connect();
|
||||
return mongoReady;
|
||||
}
|
||||
|
||||
return {
|
||||
async write(event: TelemetryIngest): Promise<WriteReport> {
|
||||
const report: WriteReport = { mongo: false, redis: false, postgres: false };
|
||||
const lastSeen = new Date(event.sentAt);
|
||||
function ensurePostgresSchema(): Promise<void> {
|
||||
postgresSchemaReady ??= (async () => {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
camera_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
location TEXT NOT NULL,
|
||||
model TEXT,
|
||||
notes TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
try {
|
||||
const client = await mongoClient();
|
||||
await client.db().collection('telemetry').insertOne({
|
||||
deviceId: event.deviceId,
|
||||
sentAt: lastSeen,
|
||||
payload: event.payload,
|
||||
ingestedAt: new Date(),
|
||||
});
|
||||
report.mongo = true;
|
||||
} catch (error) {
|
||||
console.error('mongo ingest write failed', error);
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS device_status_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
last_seen TIMESTAMPTZ NOT NULL,
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS camera_lending (
|
||||
camera_id TEXT PRIMARY KEY,
|
||||
borrower TEXT NOT NULL,
|
||||
due_date TIMESTAMPTZ NOT NULL,
|
||||
purpose TEXT,
|
||||
lent_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS camera_audit_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
camera_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
})();
|
||||
return postgresSchemaReady;
|
||||
}
|
||||
|
||||
async function ensureQueueWorker(): Promise<void> {
|
||||
workerReady ??= (async () => {
|
||||
if (queue.status === 'wait') {
|
||||
await queue.connect();
|
||||
}
|
||||
|
||||
try {
|
||||
if (redis.status === 'wait') {
|
||||
await redis.connect();
|
||||
await queue.xgroup('CREATE', streamKey, streamGroup, '$', 'MKSTREAM');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes('BUSYGROUP')) {
|
||||
throw error;
|
||||
}
|
||||
await redis.set(
|
||||
redisKey(event.deviceId),
|
||||
JSON.stringify({ lastSeen: lastSeen.toISOString(), payload: event.payload }),
|
||||
'EX',
|
||||
PRESENCE_TTL_SECONDS,
|
||||
);
|
||||
report.redis = true;
|
||||
} catch (error) {
|
||||
console.error('redis ingest write failed', error);
|
||||
}
|
||||
|
||||
workerTask ??= runWorkerLoop();
|
||||
})();
|
||||
|
||||
return workerReady;
|
||||
}
|
||||
|
||||
async function persistTelemetry(event: TelemetryIngest): Promise<WriteReport> {
|
||||
const report: WriteReport = { mongo: false, redis: false, postgres: false };
|
||||
const lastSeen = new Date(event.sentAt);
|
||||
|
||||
try {
|
||||
const client = await mongoClient();
|
||||
await client.db().collection('telemetry').insertOne({
|
||||
deviceId: event.deviceId,
|
||||
sentAt: lastSeen,
|
||||
payload: event.payload,
|
||||
ingestedAt: new Date(),
|
||||
});
|
||||
report.mongo = true;
|
||||
} catch (error) {
|
||||
console.error('mongo ingest write failed', error);
|
||||
}
|
||||
|
||||
try {
|
||||
if (redis.status === 'wait') {
|
||||
await redis.connect();
|
||||
}
|
||||
await redis.set(
|
||||
redisKey(event.deviceId),
|
||||
JSON.stringify({ lastSeen: lastSeen.toISOString(), payload: event.payload }),
|
||||
'EX',
|
||||
PRESENCE_TTL_SECONDS,
|
||||
);
|
||||
report.redis = true;
|
||||
} catch (error) {
|
||||
console.error('redis ingest write failed', error);
|
||||
}
|
||||
|
||||
try {
|
||||
await ensurePostgresSchema();
|
||||
await pool.query('BEGIN');
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO device_current_state (device_id, payload, last_seen, updated_at)
|
||||
@@ -73,26 +200,532 @@ export function createTelemetryWriter(): TelemetryWriter {
|
||||
updated_at = NOW()`,
|
||||
[event.deviceId, JSON.stringify(event.payload), lastSeen],
|
||||
);
|
||||
report.postgres = true;
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO device_status_snapshots (device_id, payload, last_seen, changed_at)
|
||||
SELECT $1, $2::jsonb, $3, NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM device_status_snapshots
|
||||
WHERE device_id = $1
|
||||
AND changed_at >= NOW() - INTERVAL '5 seconds'
|
||||
)`,
|
||||
[event.deviceId, JSON.stringify(event.payload), lastSeen],
|
||||
);
|
||||
|
||||
await pool.query('COMMIT');
|
||||
} catch (error) {
|
||||
console.error('postgres ingest write failed', error);
|
||||
await pool.query('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
|
||||
return report;
|
||||
report.postgres = true;
|
||||
} catch (error) {
|
||||
console.error('postgres ingest write failed', error);
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
async function handleStreamMessage(messageId: string, fields: string[]): Promise<void> {
|
||||
const fieldMap = new Map<string, string>();
|
||||
for (let index = 0; index < fields.length; index += 2) {
|
||||
const key = fields[index];
|
||||
const value = fields[index + 1];
|
||||
if (key !== undefined && value !== undefined) {
|
||||
fieldMap.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const raw = fieldMap.get('event');
|
||||
if (!raw) {
|
||||
await queue.xadd(
|
||||
streamDlqKey,
|
||||
'MAXLEN',
|
||||
'~',
|
||||
streamDlqMaxLen,
|
||||
'*',
|
||||
'sourceMessageId',
|
||||
messageId,
|
||||
'reason',
|
||||
'missing_event_field',
|
||||
'failedAt',
|
||||
new Date().toISOString(),
|
||||
);
|
||||
await queue.xack(streamKey, streamGroup, messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
let envelope: TelemetryQueueEnvelope;
|
||||
try {
|
||||
envelope = JSON.parse(raw) as TelemetryQueueEnvelope;
|
||||
} catch (error) {
|
||||
await queue.xadd(
|
||||
streamDlqKey,
|
||||
'MAXLEN',
|
||||
'~',
|
||||
streamDlqMaxLen,
|
||||
'*',
|
||||
'sourceMessageId',
|
||||
messageId,
|
||||
'reason',
|
||||
'invalid_json',
|
||||
'error',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
'failedAt',
|
||||
new Date().toISOString(),
|
||||
);
|
||||
await queue.xack(streamKey, streamGroup, messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
let success = false;
|
||||
let lastError = '';
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
const report = await persistTelemetry(envelope.event);
|
||||
if (report.mongo && report.redis && report.postgres) {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
lastError = `partial_write mongo=${report.mongo} redis=${report.redis} postgres=${report.postgres}`;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
await queue.xadd(
|
||||
streamDlqKey,
|
||||
'MAXLEN',
|
||||
'~',
|
||||
streamDlqMaxLen,
|
||||
'*',
|
||||
'sourceMessageId',
|
||||
messageId,
|
||||
'reason',
|
||||
'write_failed',
|
||||
'error',
|
||||
lastError,
|
||||
'event',
|
||||
JSON.stringify(envelope),
|
||||
'failedAt',
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
await queue.xack(streamKey, streamGroup, messageId);
|
||||
}
|
||||
|
||||
async function runWorkerLoop(): Promise<void> {
|
||||
while (!stopWorker) {
|
||||
try {
|
||||
const streams = (await queue.xreadgroup(
|
||||
'GROUP',
|
||||
streamGroup,
|
||||
consumerName,
|
||||
'COUNT',
|
||||
20,
|
||||
'BLOCK',
|
||||
1000,
|
||||
'STREAMS',
|
||||
streamKey,
|
||||
'>',
|
||||
)) as [string, [string, string[]][]][] | null;
|
||||
|
||||
if (!streams) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const stream of streams) {
|
||||
const entries = stream[1] ?? [];
|
||||
for (const entry of entries) {
|
||||
const messageId = entry[0];
|
||||
const fields = entry[1] ?? [];
|
||||
await handleStreamMessage(messageId, fields);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('telemetry worker loop failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async ensureDevices(seed: DeviceSeed[]): Promise<void> {
|
||||
await ensurePostgresSchema();
|
||||
if (seed.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
seed.map((device) =>
|
||||
pool.query(
|
||||
`INSERT INTO devices (camera_id, name, location, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (camera_id) DO NOTHING`,
|
||||
[device.cameraId, device.name, device.location],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
async listDevices(): Promise<CameraDevice[]> {
|
||||
await ensurePostgresSchema();
|
||||
const result = await pool.query<{
|
||||
camera_id: string;
|
||||
name: string;
|
||||
location: string;
|
||||
model: string | null;
|
||||
notes: string | null;
|
||||
is_active: boolean;
|
||||
}>(
|
||||
`SELECT camera_id, name, location, model, notes, is_active
|
||||
FROM devices
|
||||
ORDER BY camera_id ASC`,
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
cameraId: row.camera_id,
|
||||
name: row.name,
|
||||
location: row.location,
|
||||
model: row.model,
|
||||
notes: row.notes,
|
||||
isActive: row.is_active,
|
||||
}));
|
||||
},
|
||||
|
||||
async getDevice(cameraId: string): Promise<CameraDevice | null> {
|
||||
await ensurePostgresSchema();
|
||||
const result = await pool.query<{
|
||||
camera_id: string;
|
||||
name: string;
|
||||
location: string;
|
||||
model: string | null;
|
||||
notes: string | null;
|
||||
is_active: boolean;
|
||||
}>(
|
||||
`SELECT camera_id, name, location, model, notes, is_active
|
||||
FROM devices
|
||||
WHERE camera_id = $1
|
||||
LIMIT 1`,
|
||||
[cameraId],
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
cameraId: row.camera_id,
|
||||
name: row.name,
|
||||
location: row.location,
|
||||
model: row.model,
|
||||
notes: row.notes,
|
||||
isActive: row.is_active,
|
||||
};
|
||||
},
|
||||
|
||||
async createDevice(input: CameraCreateRequest): Promise<CameraDevice | null> {
|
||||
await ensurePostgresSchema();
|
||||
const result = await pool.query<{
|
||||
camera_id: string;
|
||||
}>(
|
||||
`INSERT INTO devices (camera_id, name, location, model, notes, is_active, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
ON CONFLICT (camera_id) DO NOTHING
|
||||
RETURNING camera_id`,
|
||||
[
|
||||
input.cameraId,
|
||||
input.name,
|
||||
input.location,
|
||||
input.model ?? null,
|
||||
input.notes ?? null,
|
||||
input.isActive ?? true,
|
||||
],
|
||||
);
|
||||
|
||||
if (result.rowCount !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.getDevice(input.cameraId);
|
||||
},
|
||||
|
||||
async updateDevice(cameraId: string, patch: CameraDeviceUpdate): Promise<CameraDevice | null> {
|
||||
await ensurePostgresSchema();
|
||||
|
||||
const setClauses: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (patch.name !== undefined) {
|
||||
values.push(patch.name);
|
||||
setClauses.push(`name = $${values.length}`);
|
||||
}
|
||||
if (patch.location !== undefined) {
|
||||
values.push(patch.location);
|
||||
setClauses.push(`location = $${values.length}`);
|
||||
}
|
||||
if (patch.model !== undefined) {
|
||||
values.push(patch.model);
|
||||
setClauses.push(`model = $${values.length}`);
|
||||
}
|
||||
if (patch.notes !== undefined) {
|
||||
values.push(patch.notes);
|
||||
setClauses.push(`notes = $${values.length}`);
|
||||
}
|
||||
if (patch.isActive !== undefined) {
|
||||
values.push(patch.isActive);
|
||||
setClauses.push(`is_active = $${values.length}`);
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
return this.getDevice(cameraId);
|
||||
}
|
||||
|
||||
values.push(cameraId);
|
||||
const cameraIdPosition = values.length;
|
||||
await pool.query(
|
||||
`UPDATE devices
|
||||
SET ${setClauses.join(', ')}, updated_at = NOW()
|
||||
WHERE camera_id = $${cameraIdPosition}`,
|
||||
values,
|
||||
);
|
||||
|
||||
return this.getDevice(cameraId);
|
||||
},
|
||||
|
||||
async write(event: TelemetryIngest): Promise<WriteReport> {
|
||||
await ensureQueueWorker();
|
||||
const envelope: TelemetryQueueEnvelope = {
|
||||
event,
|
||||
queuedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
await queue.xadd(
|
||||
streamKey,
|
||||
'MAXLEN',
|
||||
'~',
|
||||
streamMaxLen,
|
||||
'*',
|
||||
'event',
|
||||
JSON.stringify(envelope),
|
||||
);
|
||||
return { mongo: true, redis: true, postgres: true };
|
||||
} catch (error) {
|
||||
console.error('telemetry queue enqueue failed', error);
|
||||
return { mongo: false, redis: false, postgres: false };
|
||||
}
|
||||
},
|
||||
|
||||
async readLend(cameraId: string): Promise<CameraLendRecord | null> {
|
||||
try {
|
||||
await ensurePostgresSchema();
|
||||
const result = await pool.query<{
|
||||
borrower: string;
|
||||
due_date: Date;
|
||||
purpose: string | null;
|
||||
lent_at: Date;
|
||||
}>(
|
||||
`SELECT borrower, due_date, purpose, lent_at
|
||||
FROM camera_lending
|
||||
WHERE camera_id = $1
|
||||
LIMIT 1`,
|
||||
[cameraId],
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
borrower: row.borrower,
|
||||
dueDate: row.due_date.toISOString(),
|
||||
purpose: row.purpose ?? undefined,
|
||||
lentAt: row.lent_at.toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('postgres lend read failed', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async saveLend(cameraId: string, lend: CameraLendRecord): Promise<boolean> {
|
||||
try {
|
||||
await ensurePostgresSchema();
|
||||
const result = await pool.query(
|
||||
`INSERT INTO camera_lending (camera_id, borrower, due_date, purpose, lent_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT (camera_id) DO NOTHING`,
|
||||
[cameraId, lend.borrower, lend.dueDate, lend.purpose ?? null, lend.lentAt],
|
||||
);
|
||||
|
||||
return result.rowCount === 1;
|
||||
} catch (error) {
|
||||
console.error('postgres lend write failed', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async returnLend(cameraId: string): Promise<boolean> {
|
||||
try {
|
||||
await ensurePostgresSchema();
|
||||
const result = await pool.query('DELETE FROM camera_lending WHERE camera_id = $1', [cameraId]);
|
||||
return result.rowCount === 1;
|
||||
} catch (error) {
|
||||
console.error('postgres lend return failed', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async logAudit(
|
||||
cameraId: string,
|
||||
action: CameraAuditAction,
|
||||
actor: string,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ensurePostgresSchema();
|
||||
await pool.query(
|
||||
`INSERT INTO camera_audit_logs (camera_id, action, actor, metadata, created_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, NOW())`,
|
||||
[cameraId, action, actor, JSON.stringify(metadata)],
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('postgres audit write failed', error);
|
||||
}
|
||||
},
|
||||
|
||||
async listAudit(cameraId: string, limit = 20): Promise<CameraAuditLog[]> {
|
||||
try {
|
||||
await ensurePostgresSchema();
|
||||
const boundedLimit = Math.max(1, Math.min(100, limit));
|
||||
const result = await pool.query<{
|
||||
id: string;
|
||||
camera_id: string;
|
||||
action: CameraAuditAction;
|
||||
actor: string;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: Date;
|
||||
}>(
|
||||
`SELECT id, camera_id, action, actor, metadata, created_at
|
||||
FROM camera_audit_logs
|
||||
WHERE camera_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
[cameraId, boundedLimit],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
cameraId: row.camera_id,
|
||||
action: row.action,
|
||||
actor: row.actor,
|
||||
metadata: row.metadata ?? {},
|
||||
createdAt: row.created_at.toISOString(),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('postgres audit read failed', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async close() {
|
||||
await Promise.allSettled([mongo.close(), redis.quit(), pool.end()]);
|
||||
stopWorker = true;
|
||||
await Promise.allSettled([workerTask ?? Promise.resolve(), mongo.close(), redis.quit(), queue.quit(), pool.end()]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMemoryWriter(writes: TelemetryIngest[] = []): TelemetryWriter {
|
||||
const lends = new Map<string, CameraLendRecord>();
|
||||
const devices = new Map<string, CameraDevice>();
|
||||
const audit: CameraAuditLog[] = [];
|
||||
let nextAuditId = 1;
|
||||
|
||||
return {
|
||||
async ensureDevices(seed: DeviceSeed[]) {
|
||||
for (const item of seed) {
|
||||
if (!devices.has(item.cameraId)) {
|
||||
devices.set(item.cameraId, {
|
||||
cameraId: item.cameraId,
|
||||
name: item.name,
|
||||
location: item.location,
|
||||
model: null,
|
||||
notes: null,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
async listDevices() {
|
||||
return Array.from(devices.values()).sort((a, b) => a.cameraId.localeCompare(b.cameraId));
|
||||
},
|
||||
async getDevice(cameraId: string) {
|
||||
return devices.get(cameraId) ?? null;
|
||||
},
|
||||
async createDevice(input: CameraCreateRequest) {
|
||||
if (devices.has(input.cameraId)) {
|
||||
return null;
|
||||
}
|
||||
const created: CameraDevice = {
|
||||
cameraId: input.cameraId,
|
||||
name: input.name,
|
||||
location: input.location,
|
||||
model: input.model ?? null,
|
||||
notes: input.notes ?? null,
|
||||
isActive: input.isActive ?? true,
|
||||
};
|
||||
devices.set(created.cameraId, created);
|
||||
return created;
|
||||
},
|
||||
async updateDevice(cameraId: string, patch: CameraDeviceUpdate) {
|
||||
const current = devices.get(cameraId);
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
const updated: CameraDevice = {
|
||||
...current,
|
||||
...patch,
|
||||
};
|
||||
devices.set(cameraId, updated);
|
||||
return updated;
|
||||
},
|
||||
async write(event: TelemetryIngest) {
|
||||
writes.push(event);
|
||||
return { mongo: true, redis: true, postgres: true };
|
||||
},
|
||||
async readLend(cameraId: string) {
|
||||
return lends.get(cameraId) ?? null;
|
||||
},
|
||||
async saveLend(cameraId: string, lend: CameraLendRecord) {
|
||||
if (lends.has(cameraId)) {
|
||||
return false;
|
||||
}
|
||||
lends.set(cameraId, lend);
|
||||
return true;
|
||||
},
|
||||
async returnLend(cameraId: string) {
|
||||
return lends.delete(cameraId);
|
||||
},
|
||||
async logAudit(cameraId, action, actor, metadata = {}) {
|
||||
audit.unshift({
|
||||
id: nextAuditId,
|
||||
cameraId,
|
||||
action,
|
||||
actor,
|
||||
metadata,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
nextAuditId += 1;
|
||||
},
|
||||
async listAudit(cameraId: string, limit = 20) {
|
||||
const boundedLimit = Math.max(1, Math.min(100, limit));
|
||||
return audit.filter((item) => item.cameraId === cameraId).slice(0, boundedLimit);
|
||||
},
|
||||
async close() {
|
||||
return;
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user