feat(print): sync capture diagnostics after desktop login
This commit is contained in:
parent
ec897c96b6
commit
15007a4893
33
apps/api/src/printTelemetry.ts
Normal file
33
apps/api/src/printTelemetry.ts
Normal 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
|
||||||
|
}
|
||||||
@ -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(`
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
20
migrations/005_print_capture_diagnostics.sql
Normal file
20
migrations/005_print_capture_diagnostics.sql
Normal 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);
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user