/** * POST /api/surveillance/shipment-recording/init * * Body: { shipmentId: string|number } * * Reads the shipment's `shipping_date` (set when commissioning happened), * pulls the matching recording window off the Synology NAS, caches it * to a temp file on the Nuxt server, and returns a token that the * `stream` and `cleanup` endpoints use to find that file. */ import { mkdir } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { randomBytes } from 'node:crypto' import refreshTokenHelper from '../../../utils/refreshTokenHelper' import getTokenHelper from '../../../utils/getTokenHelper' import fetchHelper from '../../../utils/fetchHelper' import { synologyLogin, synologyLogout, synologyListRecordings, synologyRangeExport, getDefaultCameraIds, getWindowSeconds, } from '../../../utils/synologyHelper' const CACHE_DIR_NAME = 'logship-surveillance' // Hard cap on the cached file's lifetime in case the cleanup endpoint is // never called (browser tab closed mid-stream, network drop, etc.). const MAX_TTL_MS = 30 * 60 * 1000 // 30 min function cacheDir(): string { return join(tmpdir(), CACHE_DIR_NAME) } function isLimitedRole(event: any): boolean { // Mirrors the frontend `getMenuType() === 'c'` (customer) check so we // never download footage for a limited role even if the button leaks. try { const raw = getCookie(event, 'logship_role') if (!raw) return false const role = JSON.parse(raw) const fm = role?.FrontendMenu const id = typeof fm === 'object' ? (fm?.id ?? fm?.identifier) : fm return String(id || '').toLowerCase() === 'c' } catch { return false } } const fetchShipmentShippingDate = async (event: any, shipmentId: string | number, authToken: any = null): Promise => { const token = authToken ?? await getTokenHelper(event) const res: any = await fetchHelper( event, `models/m_inout/${shipmentId}?$select=shipping_date,IsCommissioned,DocumentNo`, 'GET', token, null ) const sd = res?.shipping_date return typeof sd === 'string' && sd.length > 0 ? sd : null } /** * Resolves the surveillance camera(s) that film the commissioning table this * shipment was packed at. The shipment links to CUST_CommissionTable via the * `CUST_CommissionTable_ID` FK (set at completion by the parcel routes); that * row carries `synology_camera_id` and an optional `synology_camera_2_id` * (tables with two cameras). Returns the ids in order (primary first), or an * empty array for older/un-linked shipments so the caller falls back to the * default camera. * * Done as TWO plain GETs rather than one `$expand=CUST_CommissionTable_ID`: * iDempiere does not reliably expand a *custom* to-one FK into the full row, * so the camera columns can come back missing and we'd silently fall back to * the default camera even though the table is linked. Reading the FK id off * the shipment and then fetching the commission-table row directly is robust — * a single-record GET always returns every column. `$select` on the shipment * keeps the heavy columns (DHL_Label_Base64 etc.) off the wire. */ const fetchShipmentCameras = async (event: any, shipmentId: string | number, authToken: any = null): Promise => { const token = authToken ?? await getTokenHelper(event) // 1) Read just the FK off the shipment. The un-expanded reference already // carries {id, identifier, model-name}; tolerate a bare numeric id too. const ship: any = await fetchHelper( event, `models/m_inout/${shipmentId}?$select=CUST_CommissionTable_ID`, 'GET', token, null ) const ref = ship?.CUST_CommissionTable_ID const tableId = Number(ref?.id ?? ref) if (!Number.isFinite(tableId) || tableId <= 0) { console.log('[surveillance] shipment not linked to a commission table; using default camera', { shipmentId }) return [] } // 2) Fetch the commission-table row directly for its camera id(s). const tbl: any = await fetchHelper( event, `models/cust_commissiontable/${tableId}`, 'GET', token, null ) // iDempiere OData casing varies by model — accept both. const c1 = Number(tbl?.synology_camera_id ?? tbl?.Synology_Camera_ID) const c2 = Number(tbl?.synology_camera_2_id ?? tbl?.Synology_Camera_2_ID) const ids: number[] = [] if (Number.isFinite(c1) && c1 > 0) ids.push(c1) if (Number.isFinite(c2) && c2 > 0 && c2 !== c1) ids.push(c2) console.log('[surveillance] resolved commission-table camera(s)', { shipmentId, tableId, table: tbl?.Name ?? tbl?.name ?? null, cameraIds: ids, }) return ids } type CommissionActivity = { ActionType?: string Created?: string ActionTime?: string SessionUID?: string Description?: string DurationMs?: number } const fetchCommissionActivities = async (event: any, shipmentId: string | number, authToken: any = null): Promise => { const token = authToken ?? await getTokenHelper(event) const res: any = await fetchHelper( event, `models/cust_commissionactivity?$filter=M_InOut_ID eq ${shipmentId}&$select=ActionType,Created,ActionTime,SessionUID`, 'GET', token, null ) const records = res?.records return Array.isArray(records) ? records : [] } /** * Like fetchCommissionActivities but also pulls every marker-relevant field * so the modal can render a clickable detail panel below the video (scanned * product, parcel id, shipping carrier, …). $expand=M_Product_ID is needed * to get the full {id, identifier} object for the product, not just the FK. * Best-effort. */ const fetchAllActivities = async (event: any, shipmentId: string | number, authToken: any = null): Promise => { const token = authToken ?? await getTokenHelper(event) const res: any = await fetchHelper( event, `models/cust_commissionactivity?$filter=M_InOut_ID eq ${shipmentId}&$expand=M_Product_ID`, 'GET', token, null ) const records = res?.records return Array.isArray(records) ? records : [] } // Plain ISO-Z → UNIX seconds. iDempiere `ActionTime` and `shipping_date` // are real UTC, and Synology Surveillance Station's `Recording.List` // indexes by real UTC too (verified empirically: filenames embed Berlin // local clock-time for humans, but the stored ms timestamp matches real // UTC and `fromTime`/`toTime` parameters do as well). No timezone shift // is needed — earlier code added a Berlin offset on the assumption // Synology used "Berlin-local-as-fake-UTC", which it does not; that // shift threw queries 1–2 h past every recording. // // NB: do NOT pass `Created` to this. iDempiere stamps `Created` with // Berlin local clock-time and a misleading `Z` suffix — parsing it as // UTC drifts results by the Berlin offset. Use `ActionTime` instead. const parseTs = (s: string | undefined | null): number | null => { if (!s) return null const t = Math.floor(new Date(s).getTime() / 1000) if (!Number.isFinite(t) || t <= 0) return null return t } type WindowResolution = { fromTs: number toTs: number source: 'activity-start' | 'activity-complete' | 'shipping-date' anchorTs: number // a representative timestamp for "when commissioning happened" } /** * Resolves the [from, to] surveillance window for a shipment. * * Picks the best available anchor timestamp, then applies the SAME * `[anchor - pre, anchor + post]` window math regardless of source. * Treating activity timestamps the same way as `shipping_date` makes * the path symmetric and reliable. * * Anchor priority: * 1. latest `session_start.ActionTime` (when the user began scanning) * 2. latest `session_complete.ActionTime` (if no start was logged) * 3. `m_inout.shipping_date` (older shipments without activity rows) * * IMPORTANT: we anchor on `ActionTime`, NOT `Created`. iDempiere stores * `ActionTime` as real UTC (like `shipping_date`), but stamps `Created` * with Berlin-local clock time and a *misleading* `Z` suffix — parsing * `Created` as UTC drifts results forward by the Berlin offset * (+1 h CET / +2 h CEST), pointing the Synology query 1–2 hours past * the real recording. * * parseTs is a plain ISO-Z → unix conversion (no shift). Synology's * RangeExport / Recording.List `fromTime` / `toTime` parameters are * real UTC seconds, matching what we pass straight through. */ const resolveWindow = async (event: any, shipmentId: string | number): Promise => { const { pre, post } = getWindowSeconds() // Try the activity log first. let activities: CommissionActivity[] = [] try { activities = await fetchCommissionActivities(event, shipmentId) } catch (err) { try { const refreshed = await refreshTokenHelper(event) activities = await fetchCommissionActivities(event, shipmentId, refreshed) } catch { activities = [] } } if (activities.length > 0) { // Anchor the window to the actual session boundaries when both ends // exist: from `session_start - pre` to `session_complete + post`, // so the entire scanning + labelling sequence is captured no matter // how long it took. SYNOLOGY_POST_SECONDS still applies — it's a // small post-roll past the *complete* event, not a hard duration. // // If only session_start (or only session_complete) is recorded, we // fall back to a single-anchor window with the configured pre/post. const latestOf = (type: string) => activities .filter(a => a.ActionType === type && parseTs(a.ActionTime)) .sort((a, b) => parseTs(b.ActionTime)! - parseTs(a.ActionTime)!)[0] const start = latestOf('session_start') const complete = latestOf('session_complete') if (start && complete) { const startTs = parseTs(start.ActionTime)! const completeTs = parseTs(complete.ActionTime)! // Defensive: if complete is somehow before start (clock skew / // out-of-order activity rows), don't invert the window — fall // through to the single-anchor branch using start. if (completeTs >= startTs) { return { fromTs: startTs - pre, toTs: completeTs + post, anchorTs: startTs, source: 'activity-start', } } } const anchor = start ?? complete if (anchor) { const anchorTs = parseTs(anchor.ActionTime)! return { fromTs: anchorTs - pre, toTs: anchorTs + post, anchorTs, source: start ? 'activity-start' : 'activity-complete', } } } // Fallback: shipping_date on the shipment record. let shippingDate: string | null = null try { shippingDate = await fetchShipmentShippingDate(event, shipmentId) } catch (err) { try { const refreshed = await refreshTokenHelper(event) shippingDate = await fetchShipmentShippingDate(event, shipmentId, refreshed) } catch (error: any) { throw createError({ statusCode: 502, statusMessage: 'Failed to load shipment.' }) } } if (!shippingDate) { throw createError({ statusCode: 404, statusMessage: 'No commissioning activity or shipping_date found for this shipment.' }) } const ts = parseTs(shippingDate) if (!ts) { throw createError({ statusCode: 400, statusMessage: `Invalid shipping_date: ${shippingDate}` }) } // shipping_date is set when commissioning *finishes* (label printed, // session_complete fired). The interesting frame is what happens after // — sealing the parcel, sticking the label, handover — so we use a // narrower window starting at the moment with 2 min of post-roll. // Activity-based windows still get the configured pre/post since the // activity timestamps anchor on the *start* of scanning. return { fromTs: ts, toTs: ts + 120, anchorTs: ts, source: 'shipping-date' } } export default defineEventHandler(async (event) => { if (isLimitedRole(event)) { throw createError({ statusCode: 403, statusMessage: 'Not allowed for this role.' }) } const body = await readBody(event) const shipmentId = body?.shipmentId if (!shipmentId) { throw createError({ statusCode: 400, statusMessage: 'shipmentId is required.' }) } // 1) Resolve [from, to] (and a representative anchor ts) from commission // activity records, falling back to shipping_date for older orders. // `let` because if the activity-derived window returns zero recordings // (stale/aborted session, clock skew between iDempiere and the NAS, // timestamp landing in a long motion-detection gap), we rebind to the // shipping_date window further down rather than 404 the modal. const window = await resolveWindow(event, shipmentId) let fromTs = window.fromTs let toTs = window.toTs let commissionTs = window.anchorTs let windowSource: 'activity-start' | 'activity-complete' | 'shipping-date' | 'shipping-date-fallback' = window.source // Pick the camera that films THIS shipment's commissioning table. Each Tisch // has its own camera; the table is linked on the shipment (CUST_CommissionTable_ID) // at completion. Falls back to the default camera for older/un-linked shipments. let cameraIds = getDefaultCameraIds() try { const tableCams = await fetchShipmentCameras(event, shipmentId) if (tableCams.length) cameraIds = tableCams } catch (err: any) { try { const refreshed = await refreshTokenHelper(event) const tableCams = await fetchShipmentCameras(event, shipmentId, refreshed) if (tableCams.length) cameraIds = tableCams } catch (err2: any) { // keep default camera — but surface why the table-camera lookup failed // so a broken FK read doesn't masquerade as "no table linked". console.warn('[surveillance] commission-table camera lookup failed; using default camera', { shipmentId, error: err2?.statusMessage ?? err2?.data ?? err2?.message ?? String(err2), }) } } // 2) Pull all activity rows (not just start/complete) so the modal can // render markers on the timeline + a click-to-detail panel. Best-effort // — if it fails we just ship an empty list and the video still plays. type EventOut = { ts: number actionTime: string type: string description: string durationMs: number qty: number | null productId: number | string | null productName: string | null productValue: string | null productSku: string | null productUpc: string | null // Strapi documentId — used by the modal to fetch the product image // from `/api/materials/products/{documentId}/galleries` for // item_scanned events. Strapi v5's REST API keys `/api/m-products/` // by documentId (string), not the numeric Strapi_Product_ID. productStrapiDocumentId: string | null shippingService: string | null // Position inside the merged MP4 (seconds from clip start). Computed // server-side because the merged clip skips motion-detection gaps, // so the modal can't derive this from wall-clock time alone. clipOffsetSec?: number } let events: EventOut[] = [] try { let all: any[] try { all = await fetchAllActivities(event, shipmentId) } catch { // The access token can expire between resolveWindow and here. Retry // once with a refreshed token (the standard route pattern) so a // transient auth blip doesn't silently drop the timeline markers — // this is the fetch that feeds the event dots the user clicks through. const refreshed = await refreshTokenHelper(event) all = await fetchAllActivities(event, shipmentId, refreshed) } events = all .map((a: any) => { const p = a.M_Product_ID return { // Anchor on ActionTime (real UTC). Created is Berlin-local-with- // a-lying-Z-suffix and would push every marker 1-2 h past where // it belongs on the timeline. See resolveWindow's docblock. ts: parseTs(a.ActionTime) ?? 0, actionTime: typeof a.ActionTime === 'string' ? a.ActionTime : '', type: String(a.ActionType ?? ''), description: String(a.Description ?? ''), durationMs: Number(a.DurationMs ?? 0) || 0, qty: a.Qty != null ? Number(a.Qty) : null, productId: p?.id ?? null, // With $expand=M_Product_ID we get the full product object — pull // Name first; fall back to `identifier` if iDempiere ever returns // the un-expanded reference shape. productName: p?.Name ?? p?.identifier ?? null, productValue: p?.Value ?? null, productSku: p?.SKU ?? null, productUpc: p?.UPC ?? null, productStrapiDocumentId: typeof p?.Strapi_Product_documentId === 'string' && p.Strapi_Product_documentId ? p.Strapi_Product_documentId : null, shippingService: a.ShippingService ?? null, } }) .filter((e: EventOut) => e.ts > 0) .sort((a: EventOut, b: EventOut) => a.ts - b.ts) } catch { events = [] } // 3) Talk to Synology: log in, find the matching recording, download it. // A table can have two cameras (synology_camera_id + synology_camera_2_id); // we export each over the SAME window into its own cached file/token so the // modal can play them side by side. const session = await synologyLogin() const clips: Array<{ token: string; cameraId: number; sizeBytes: number; filePath: string }> = [] let recordingStart: number | null = null let clipStart: number | null = null try { let recs = await synologyListRecordings(session, cameraIds, fromTs, toTs) // If the activity-derived window returns nothing, retry with the // shipment's shipping_date window. Activity Created can land in a // long motion-detection dead zone (cameras here are motion-triggered; // ~30 s gaps between adjacent chunks are common), or point at an // aborted earlier session. shipping_date is the timestamp the live // test harness verifies and is empirically the most reliable anchor. if (!recs.length && (windowSource === 'activity-start' || windowSource === 'activity-complete')) { let shippingDate: string | null = null try { shippingDate = await fetchShipmentShippingDate(event, shipmentId) } catch { try { const refreshed = await refreshTokenHelper(event) shippingDate = await fetchShipmentShippingDate(event, shipmentId, refreshed) } catch { shippingDate = null } } const fallbackTs = shippingDate ? parseTs(shippingDate) : null if (fallbackTs) { // shipping_date is the completion moment — keep the same // 0/+120 window the primary shipping-date branch uses so the // fallback path is consistent. console.warn('[surveillance] activity window empty, retrying with shipping_date', { shipmentId, originalSource: windowSource, originalFromTs: fromTs, originalToTs: toTs, fallbackFromTs: fallbackTs, fallbackToTs: fallbackTs + 120, }) fromTs = fallbackTs toTs = fallbackTs + 120 commissionTs = fallbackTs windowSource = 'shipping-date-fallback' recs = await synologyListRecordings(session, cameraIds, fromTs, toTs) } } // If the table-specific camera(s) still have no footage in the window // (camera id mis-mapped on the CUST_CommissionTable row, camera offline, // or footage rolled off), fall back to the default camera(s). Before the // commission-table integration the modal always used the default camera, // so this restores that behaviour instead of 404-ing the whole modal — // a 404 here takes the video AND its event timeline/markers down with it. if (!recs.length) { const defaults = getDefaultCameraIds() if (defaults.length && defaults.join(',') !== cameraIds.join(',')) { console.warn('[surveillance] no recordings for table camera(s); falling back to default camera(s)', { shipmentId, tableCameras: cameraIds, defaultCameras: defaults, }) cameraIds = defaults recs = await synologyListRecordings(session, cameraIds, fromTs, toTs) } } if (!recs.length) { throw createError({ statusCode: 404, statusMessage: 'No surveillance recording found for this shipment\'s commissioning window.' }) } // The List call above is just a sanity check / fallback trigger — // we do NOT download those individual chunks. Instead we hand the // [fromTs, toTs] window to Synology's RangeExport API, which // returns a single MP4 covering exactly the requested range. With // continuous recording the result is timeline-aligned to wall // clock 1:1, so marker math is just (event.ts - fromTs). await mkdir(cacheDir(), { recursive: true }) // Export each camera over the same window. If one camera fails (offline, // no footage), keep the others rather than failing the whole modal. for (const cameraId of cameraIds) { const tk = randomBytes(16).toString('hex') const fp = join(cacheDir(), `${tk}.mp4`) try { const exportResult = await synologyRangeExport(session, cameraId, fromTs, toTs, fp) clips.push({ token: tk, cameraId, sizeBytes: exportResult.bytes, filePath: fp }) } catch (err: any) { console.warn('[surveillance] RangeExport failed for camera', cameraId, err?.statusMessage ?? err?.message ?? err) } } if (!clips.length) { throw createError({ statusCode: 502, statusMessage: 'Failed to export the commissioning recording from the camera(s).' }) } // The clip starts at fromTs in wall-clock terms. Markers position // by event.ts - fromTs (clamped to ≥ 0). Falls back gracefully for // events that predate the window (e.g. session_start fired a few // seconds before fromTs because of clock skew). Both cameras share the // same window, so the offsets apply to every clip. recordingStart = fromTs clipStart = fromTs events = events.map(e => ({ ...e, clipOffsetSec: Math.max(0, e.ts - fromTs), })) } finally { await synologyLogout(session) } // One-line resolution summary — makes it obvious from the logs whether the // commission-table camera was used (vs default), which window anchor won, // and how many activity markers the timeline got. console.log('[surveillance] init resolved', { shipmentId, windowSource, cameraIds, usingDefaultCamera: cameraIds.join(',') === getDefaultCameraIds().join(','), events: events.length, clips: clips.map(c => c.cameraId), }) // 4) Schedule a fallback cleanup so the files can't linger if the modal // is never closed. We don't track the timer; on a successful cleanup // call the unlink will already have happened and this is a no-op. const filePaths = clips.map(c => c.filePath) setTimeout(() => { import('node:fs/promises').then(({ unlink }) => { for (const fp of filePaths) unlink(fp).catch(() => null) }) }, MAX_TTL_MS).unref?.() return { // Backward-compatible single-clip fields (= primary camera) so older // clients keep working. token: clips[0].token, sizeBytes: clips[0].sizeBytes, fromTs, toTs, commissionTs, recordingStartTs: clipStart ?? recordingStart, cameraIds, windowSource, events, // One clip per camera (1 or 2). Each carries its own stream token. clips: clips.map(c => ({ token: c.token, cameraId: c.cameraId, sizeBytes: c.sizeBytes })), } })