diff --git a/apps/api/src/printTelemetry.ts b/apps/api/src/printTelemetry.ts new file mode 100644 index 0000000..8962775 --- /dev/null +++ b/apps/api/src/printTelemetry.ts @@ -0,0 +1,33 @@ +export const streamFormats = ['zpl', 'pdf', 'postscript', 'pjl', 'pcl', 'zip', 'unknown'] as const +export const failureKinds = ['capture', 'forward', 'parser'] as const + +export type PrintTelemetry = { + id: string + source: 'raw-proxy' + streamFormat: (typeof streamFormats)[number] + byteCount: number + barcodeCount: number + forwarded: boolean + failureKind: (typeof failureKinds)[number] | null + appVersion: string + capturedAt: string +} + +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +export function parsePrintTelemetry(payload: unknown): PrintTelemetry | null { + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return null + const value = payload as Record + const allowed = ['id', 'source', 'streamFormat', 'byteCount', 'barcodeCount', 'forwarded', 'failureKind', 'appVersion', 'capturedAt'] + if (Object.keys(value).some((key) => !allowed.includes(key))) return null + if (typeof value.id !== 'string' || !uuid.test(value.id)) return null + if (value.source !== 'raw-proxy') return null + if (!streamFormats.includes(value.streamFormat as PrintTelemetry['streamFormat'])) return null + if (!Number.isSafeInteger(value.byteCount) || (value.byteCount as number) < 0) return null + if (!Number.isSafeInteger(value.barcodeCount) || (value.barcodeCount as number) < 0) return null + if (typeof value.forwarded !== 'boolean') return null + if (value.failureKind !== null && !failureKinds.includes(value.failureKind as (typeof failureKinds)[number])) return null + if (typeof value.appVersion !== 'string' || value.appVersion.length > 40) return null + if (typeof value.capturedAt !== 'string' || !Number.isFinite(Date.parse(value.capturedAt))) return null + return value as PrintTelemetry +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 25e1d51..ecc1360 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -278,6 +278,20 @@ app.post('/api/v1/sync', requireAccess('operator'), async (req, res) => { } }) +app.get('/api/v1/print-captures', requireAccess('operator'), async (req, res) => { + const requested = Number(req.query.limit ?? 50) + const limit = Number.isFinite(requested) ? Math.max(1, Math.min(200, Math.trunc(requested))) : 50 + const result = await pool.query(` + SELECT id, device_id AS "deviceId", source, stream_format AS "streamFormat", + byte_count AS "byteCount", barcode_count AS "barcodeCount", forwarded, + failure_kind AS "failureKind", app_version AS "appVersion", + captured_at AS "capturedAt", received_at AS "receivedAt" + FROM print_capture_diagnostics WHERE tenant_id = $1 + ORDER BY captured_at DESC LIMIT $2 + `, [config.tenantId, limit]) + res.json({ captures: result.rows }) +}) + app.get('/api/v1/scans', requireAccess('supervisor'), async (req, res) => { const limit = Math.max(1, Math.min(500, Number(req.query.limit ?? 100))) const result = await pool.query(` diff --git a/apps/api/src/sync.ts b/apps/api/src/sync.ts index d393362..b0893b2 100644 --- a/apps/api/src/sync.ts +++ b/apps/api/src/sync.ts @@ -3,6 +3,25 @@ import type { PoolClient } from 'pg' import type { OutboxEvent, SyncAcknowledgement } from '@fieldlens/contracts' import type { AuthIdentity } from './auth.js' import type { DeviceContext } from './context.js' +import { parsePrintTelemetry } from './printTelemetry.js' + +async function insertPrintTelemetry(client: PoolClient, event: OutboxEvent, context: DeviceContext, identity: AuthIdentity) { + const payload = parsePrintTelemetry(event.payload) + if (!payload || payload.id !== event.aggregateId) throw new Error('invalid_print_capture_diagnostic') + await client.query(` + INSERT INTO print_capture_diagnostics ( + id, tenant_id, site_id, station_id, device_id, operator_subject, + source, stream_format, byte_count, barcode_count, forwarded, + failure_kind, app_version, captured_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) + ON CONFLICT (id) DO NOTHING + `, [ + payload.id, context.tenantId, context.siteId, context.stationId, context.deviceId, + identity.subject, payload.source, payload.streamFormat, payload.byteCount, + payload.barcodeCount, payload.forwarded, payload.failureKind, + payload.appVersion, payload.capturedAt, + ]) +} async function insertScan(client: PoolClient, event: OutboxEvent, context: DeviceContext, identity: AuthIdentity) { const payload = event.payload as Record @@ -123,6 +142,9 @@ async function completePallet(client: PoolClient, event: OutboxEvent) { async function applyDomainEvent(client: PoolClient, event: OutboxEvent, context: DeviceContext, identity: AuthIdentity) { switch (event.eventType) { + case 'print.capture.diagnostic': + await insertPrintTelemetry(client, event, context, identity) + return case 'scan.captured': await insertScan(client, event, context, identity) return diff --git a/migrations/005_print_capture_diagnostics.sql b/migrations/005_print_capture_diagnostics.sql new file mode 100644 index 0000000..6cc8031 --- /dev/null +++ b/migrations/005_print_capture_diagnostics.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS print_capture_diagnostics ( + id uuid PRIMARY KEY, + tenant_id text NOT NULL REFERENCES tenants(id), + site_id uuid NOT NULL REFERENCES sites(id), + station_id uuid NOT NULL REFERENCES stations(id), + device_id uuid NOT NULL REFERENCES devices(id), + operator_subject text NOT NULL, + source text NOT NULL, + stream_format text NOT NULL, + byte_count bigint NOT NULL CHECK (byte_count >= 0), + barcode_count integer NOT NULL CHECK (barcode_count >= 0), + forwarded boolean NOT NULL, + failure_kind text, + app_version text NOT NULL, + captured_at timestamptz NOT NULL, + received_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_print_capture_diagnostics_tenant_time + ON print_capture_diagnostics(tenant_id, captured_at DESC); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 18283a5..5eee892 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -56,6 +56,7 @@ export type OutboxEvent = { | 'pallet.session.started' | 'pallet.observation.captured' | 'pallet.session.completed' + | 'print.capture.diagnostic' schemaVersion: SchemaVersion payload: unknown createdAt: string