feat(print): sync capture diagnostics after desktop login
Some checks failed
CI / verify (push) Has been cancelled
CI / print-rust (push) Has been cancelled

This commit is contained in:
Admin 2026-09-26 22:15:40 +00:00
parent ec897c96b6
commit 15007a4893
5 changed files with 90 additions and 0 deletions

View File

@ -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<string, unknown>
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
}

View File

@ -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) => { app.get('/api/v1/scans', requireAccess('supervisor'), async (req, res) => {
const limit = Math.max(1, Math.min(500, Number(req.query.limit ?? 100))) const limit = Math.max(1, Math.min(500, Number(req.query.limit ?? 100)))
const result = await pool.query(` const result = await pool.query(`

View File

@ -3,6 +3,25 @@ import type { PoolClient } from 'pg'
import type { OutboxEvent, SyncAcknowledgement } from '@fieldlens/contracts' import type { OutboxEvent, SyncAcknowledgement } from '@fieldlens/contracts'
import type { AuthIdentity } from './auth.js' import type { AuthIdentity } from './auth.js'
import type { DeviceContext } from './context.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) { async function insertScan(client: PoolClient, event: OutboxEvent, context: DeviceContext, identity: AuthIdentity) {
const payload = event.payload as Record<string, unknown> const payload = event.payload as Record<string, unknown>
@ -123,6 +142,9 @@ async function completePallet(client: PoolClient, event: OutboxEvent) {
async function applyDomainEvent(client: PoolClient, event: OutboxEvent, context: DeviceContext, identity: AuthIdentity) { async function applyDomainEvent(client: PoolClient, event: OutboxEvent, context: DeviceContext, identity: AuthIdentity) {
switch (event.eventType) { switch (event.eventType) {
case 'print.capture.diagnostic':
await insertPrintTelemetry(client, event, context, identity)
return
case 'scan.captured': case 'scan.captured':
await insertScan(client, event, context, identity) await insertScan(client, event, context, identity)
return return

View File

@ -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);

View File

@ -56,6 +56,7 @@ export type OutboxEvent = {
| 'pallet.session.started' | 'pallet.session.started'
| 'pallet.observation.captured' | 'pallet.observation.captured'
| 'pallet.session.completed' | 'pallet.session.completed'
| 'print.capture.diagnostic'
schemaVersion: SchemaVersion schemaVersion: SchemaVersion
payload: unknown payload: unknown
createdAt: string createdAt: string