/** * 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 } 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 const cameraIds = getDefaultCameraIds() // 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 { const all = await fetchAllActivities(event, shipmentId) 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. const session = await synologyLogin() let token = '' let filePath = '' let bytes = 0 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 (!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 }) token = randomBytes(16).toString('hex') filePath = join(cacheDir(), `${token}.mp4`) const cameraId = cameraIds[0] const exportResult = await synologyRangeExport(session, cameraId, fromTs, toTs, filePath) bytes = exportResult.bytes // 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). recordingStart = fromTs clipStart = fromTs events = events.map(e => ({ ...e, clipOffsetSec: Math.max(0, e.ts - fromTs), })) } finally { await synologyLogout(session) } // 4) Schedule a fallback cleanup so the file 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. setTimeout(() => { import('node:fs/promises').then(({ unlink }) => unlink(filePath).catch(() => null)) }, MAX_TTL_MS).unref?.() return { token, sizeBytes: bytes, fromTs, toTs, commissionTs, recordingStartTs: clipStart ?? recordingStart, cameraIds, windowSource, events, } })