diff --git a/README.md b/README.md index 2e392c1..9d16a83 100644 --- a/README.md +++ b/README.md @@ -133,28 +133,28 @@ Target: - `apps/main/src/lib/domains/mobile/*` (new) - `apps/main/src/routes/(main)/devices/[deviceId]` (new) -- [ ] Add remote functions: +- [x] Add remote functions: - `getDevicesSQ` - `getDeviceDetailSQ` - `getDeviceSmsSQ` - `getDeviceMediaSQ` -- [ ] Add VM (`devices.vm.svelte.ts`) that: +- [x] Add VM (`devices.vm.svelte.ts`) that: - fetches devices list - fetches selected device detail - polls SMS every 5 seconds while device view is open - handles loading/error states -- [ ] Add UI pages/components: +- [x] Add UI pages/components: - `/dashboard` list with device identity + last ping - `/devices/[deviceId]` detail with tabs: - Device info - SMS feed - Media assets list -- [ ] Add sidebar/navigation entry for Devices. +- [x] Add sidebar/navigation entry for Devices. Definition of done: -- [ ] Admin can browse devices and open each device detail. -- [ ] SMS view refreshes every 5 seconds and shows new data. +- [x] Admin can browse devices and open each device detail. +- [x] SMS view refreshes every 5 seconds and shows new data. ### Phase 5: Observability Stage 1 @@ -163,24 +163,24 @@ Targets: - `packages/logic/core/observability.ts` (use existing helpers) - Processor/mobile domain handlers and repository/controller paths -- [ ] Add span names for key flows: +- [x] Add span names for key flows: - `mobile.register` - `mobile.ping` - `mobile.sms.sync` - `mobile.media.sync` - `mobile.devices.list` - `mobile.device.detail` -- [ ] Add structured domain events with device id, counts, durations. -- [ ] Ensure errors include `flowId` consistently. +- [x] Add structured domain events with device id, counts, durations. +- [x] Ensure errors include `flowId` consistently. Definition of done: -- [ ] Can trace one request from processor endpoint to DB operation via shared `flowId`. +- [x] Can trace one request from processor endpoint to DB operation via shared `flowId`. ### Phase 6: Handoff Readiness (Team Test Phase) -- [ ] Prepare endpoint payload examples for mobile team (in a `spec.mobile.md` file). -- [ ] Provide a short operator checklist: +- [x] Prepare endpoint payload examples for mobile team (in a `spec.mobile.md` file). +- [x] Provide a short operator checklist: - register device - verify ping updates - verify sms appears in admin diff --git a/apps/main/src/lib/core/constants.ts b/apps/main/src/lib/core/constants.ts index dad2799..d030983 100644 --- a/apps/main/src/lib/core/constants.ts +++ b/apps/main/src/lib/core/constants.ts @@ -1,4 +1,5 @@ import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard"; +import Smartphone from "@lucide/svelte/icons/smartphone"; import { BellRingIcon, UsersIcon } from "@lucide/svelte"; import UserCircle from "~icons/lucide/user-circle"; @@ -25,6 +26,11 @@ export const mainNavTree = [ url: "/users", icon: UsersIcon, }, + { + title: "Devices", + url: "/devices", + icon: Smartphone, + }, ] as AppSidebarItem[]; export const secondaryNavTree = [ diff --git a/apps/main/src/lib/domains/mobile/mobile.remote.ts b/apps/main/src/lib/domains/mobile/mobile.remote.ts new file mode 100644 index 0000000..bbb43a2 --- /dev/null +++ b/apps/main/src/lib/domains/mobile/mobile.remote.ts @@ -0,0 +1,110 @@ +import { getMobileController } from "@pkg/logic/domains/mobile/controller"; +import { + mobilePaginationSchema, + listMobileDeviceMediaFiltersSchema, + listMobileDeviceSMSFiltersSchema, +} from "@pkg/logic/domains/mobile/data"; +import { + getFlowExecCtxForRemoteFuncs, + unauthorized, +} from "$lib/core/server.utils"; +import { getRequestEvent, query } from "$app/server"; +import * as v from "valibot"; + +const mc = getMobileController(); + +const getDevicesInputSchema = v.object({ + search: v.optional(v.string()), + pagination: mobilePaginationSchema, +}); + +const getDeviceDetailInputSchema = v.object({ + deviceId: v.pipe(v.number(), v.integer()), +}); + +const getDeviceSMSInputSchema = v.object({ + deviceId: v.pipe(v.number(), v.integer()), + search: v.optional(v.string()), + pagination: mobilePaginationSchema, +}); + +const getDeviceMediaInputSchema = v.object({ + deviceId: v.pipe(v.number(), v.integer()), + mimeType: v.optional(v.string()), + search: v.optional(v.string()), + pagination: mobilePaginationSchema, +}); + +export const getDevicesSQ = query(getDevicesInputSchema, async (input) => { + const event = getRequestEvent(); + const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); + if (!fctx.userId) { + return unauthorized(fctx); + } + + const res = await mc.listDevices( + fctx, + { ownerUserId: fctx.userId, search: input.search }, + input.pagination, + ); + + return res.isOk() + ? { data: res.value, error: null } + : { data: null, error: res.error }; +}); + +export const getDeviceDetailSQ = query( + getDeviceDetailInputSchema, + async (input) => { + const event = getRequestEvent(); + const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); + if (!fctx.userId) { + return unauthorized(fctx); + } + + const res = await mc.getDeviceDetail(fctx, input.deviceId, fctx.userId); + return res.isOk() + ? { data: res.value, error: null } + : { data: null, error: res.error }; + }, +); + +export const getDeviceSmsSQ = query(getDeviceSMSInputSchema, async (input) => { + const event = getRequestEvent(); + const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); + if (!fctx.userId) { + return unauthorized(fctx); + } + + const filters = v.parse(listMobileDeviceSMSFiltersSchema, { + deviceId: input.deviceId, + search: input.search, + }); + + const res = await mc.listDeviceSMS(fctx, filters, input.pagination); + return res.isOk() + ? { data: res.value, error: null } + : { data: null, error: res.error }; +}); + +export const getDeviceMediaSQ = query( + getDeviceMediaInputSchema, + async (input) => { + const event = getRequestEvent(); + const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); + if (!fctx.userId) { + return unauthorized(fctx); + } + + const filters = v.parse(listMobileDeviceMediaFiltersSchema, { + deviceId: input.deviceId, + search: input.search, + mimeType: input.mimeType, + }); + + const res = await mc.listDeviceMedia(fctx, filters, input.pagination); + return res.isOk() + ? { data: res.value, error: null } + : { data: null, error: res.error }; + }, +); diff --git a/apps/main/src/lib/domains/mobile/mobile.vm.svelte.ts b/apps/main/src/lib/domains/mobile/mobile.vm.svelte.ts new file mode 100644 index 0000000..1238680 --- /dev/null +++ b/apps/main/src/lib/domains/mobile/mobile.vm.svelte.ts @@ -0,0 +1,203 @@ +import type { + MobileDevice, + MobileDeviceDetail, + MobileMediaAsset, + MobileSMS, +} from "@pkg/logic/domains/mobile/data"; +import { + getDeviceDetailSQ, + getDeviceMediaSQ, + getDeviceSmsSQ, + getDevicesSQ, +} from "./mobile.remote"; +import { toast } from "svelte-sonner"; + +class MobileViewModel { + devices = $state([] as MobileDevice[]); + devicesLoading = $state(false); + devicesSearch = $state(""); + devicesPage = $state(1); + devicesPageSize = $state(25); + devicesTotal = $state(0); + + selectedDeviceDetail = $state(null as MobileDeviceDetail | null); + deviceDetailLoading = $state(false); + + sms = $state([] as MobileSMS[]); + smsLoading = $state(false); + smsPage = $state(1); + smsPageSize = $state(25); + smsTotal = $state(0); + + media = $state([] as MobileMediaAsset[]); + mediaLoading = $state(false); + mediaPage = $state(1); + mediaPageSize = $state(25); + mediaTotal = $state(0); + + private devicesPollTimer: ReturnType | null = null; + private smsPollTimer: ReturnType | null = null; + + async fetchDevices() { + this.devicesLoading = true; + try { + const result = await getDevicesSQ({ + search: this.devicesSearch || undefined, + pagination: { + page: this.devicesPage, + pageSize: this.devicesPageSize, + sortBy: "lastPingAt", + sortOrder: "desc", + }, + }); + + if (result?.error || !result?.data) { + toast.error(result?.error?.message || "Failed to load devices", { + description: result?.error?.description || "Please try again later", + }); + return; + } + + this.devices = result.data.data as MobileDevice[]; + this.devicesTotal = result.data.total; + } catch (error) { + toast.error("Failed to load devices", { + description: + error instanceof Error ? error.message : "Please try again later", + }); + } finally { + this.devicesLoading = false; + } + } + + async fetchDeviceDetail(deviceId: number) { + this.deviceDetailLoading = true; + try { + const result = await getDeviceDetailSQ({ deviceId }); + if (result?.error || !result?.data) { + toast.error(result?.error?.message || "Failed to load device details", { + description: result?.error?.description || "Please try again later", + }); + return; + } + this.selectedDeviceDetail = result.data as MobileDeviceDetail; + } catch (error) { + toast.error("Failed to load device details", { + description: + error instanceof Error ? error.message : "Please try again later", + }); + } finally { + this.deviceDetailLoading = false; + } + } + + async fetchSMS(deviceId: number) { + this.smsLoading = true; + try { + const result = await getDeviceSmsSQ({ + deviceId, + pagination: { + page: this.smsPage, + pageSize: this.smsPageSize, + sortBy: "sentAt", + sortOrder: "desc", + }, + }); + if (result?.error || !result?.data) { + toast.error(result?.error?.message || "Failed to load SMS", { + description: result?.error?.description || "Please try again later", + }); + return; + } + this.sms = result.data.data as MobileSMS[]; + this.smsTotal = result.data.total; + } catch (error) { + toast.error("Failed to load SMS", { + description: + error instanceof Error ? error.message : "Please try again later", + }); + } finally { + this.smsLoading = false; + } + } + + async fetchMedia(deviceId: number) { + this.mediaLoading = true; + try { + const result = await getDeviceMediaSQ({ + deviceId, + pagination: { + page: this.mediaPage, + pageSize: this.mediaPageSize, + sortBy: "createdAt", + sortOrder: "desc", + }, + }); + if (result?.error || !result?.data) { + toast.error(result?.error?.message || "Failed to load media assets", { + description: result?.error?.description || "Please try again later", + }); + return; + } + this.media = result.data.data as MobileMediaAsset[]; + this.mediaTotal = result.data.total; + } catch (error) { + toast.error("Failed to load media assets", { + description: + error instanceof Error ? error.message : "Please try again later", + }); + } finally { + this.mediaLoading = false; + } + } + + startDevicesPolling(intervalMs = 5000) { + this.stopDevicesPolling(); + this.devicesPollTimer = setInterval(() => { + this.fetchDevices(); + }, intervalMs); + } + + stopDevicesPolling() { + if (this.devicesPollTimer) { + clearInterval(this.devicesPollTimer); + this.devicesPollTimer = null; + } + } + + startSmsPolling(deviceId: number, intervalMs = 5000) { + this.stopSmsPolling(); + this.smsPollTimer = setInterval(() => { + this.fetchSMS(deviceId); + }, intervalMs); + } + + stopSmsPolling() { + if (this.smsPollTimer) { + clearInterval(this.smsPollTimer); + this.smsPollTimer = null; + } + } + + formatLastPing(lastPingAt: Date | null | undefined) { + if (!lastPingAt) { + return "Never"; + } + const pingDate = + lastPingAt instanceof Date ? lastPingAt : new Date(lastPingAt); + const diffSeconds = Math.floor((Date.now() - pingDate.getTime()) / 1000); + + if (diffSeconds < 60) { + return `${diffSeconds}s ago`; + } + if (diffSeconds < 3600) { + return `${Math.floor(diffSeconds / 60)}m ago`; + } + if (diffSeconds < 86400) { + return `${Math.floor(diffSeconds / 3600)}h ago`; + } + return pingDate.toLocaleString(); + } +} + +export const mobileVM = new MobileViewModel(); diff --git a/apps/main/src/routes/(main)/dashboard/+page.svelte b/apps/main/src/routes/(main)/dashboard/+page.svelte index 797b356..0df13f4 100644 --- a/apps/main/src/routes/(main)/dashboard/+page.svelte +++ b/apps/main/src/routes/(main)/dashboard/+page.svelte @@ -1,18 +1,112 @@ - -
-

- Dashboard Not Yet Implemented -

-

- This is where your implementation will go -

-
+ + + +
+
+ + Devices + + {mobileVM.devicesTotal} total + +
+ +
+ +
+ + { + mobileVM.devicesPage = 1; + mobileVM.fetchDevices(); + }} + /> +
+
+ + + {#if !mobileVM.devicesLoading && mobileVM.devices.length === 0} +
+ No devices registered yet. +
+ {:else} + + + + Device + Manufacturer / Model + Android + Last Ping + + + + {#each mobileVM.devices as device (device.id)} + goto(`/devices/${device.id}`)} + > + +
{device.name}
+
+ {device.externalDeviceId} +
+
+ + {device.manufacturer} / {device.model} + + {device.androidVersion} + + {mobileVM.formatLastPing(device.lastPingAt)} + +
+ {/each} +
+
+ {/if} +
+
diff --git a/apps/main/src/routes/(main)/devices/+page.svelte b/apps/main/src/routes/(main)/devices/+page.svelte new file mode 100644 index 0000000..4e3b92d --- /dev/null +++ b/apps/main/src/routes/(main)/devices/+page.svelte @@ -0,0 +1,8 @@ + diff --git a/apps/main/src/routes/(main)/devices/[deviceId]/+page.svelte b/apps/main/src/routes/(main)/devices/[deviceId]/+page.svelte new file mode 100644 index 0000000..242d7b1 --- /dev/null +++ b/apps/main/src/routes/(main)/devices/[deviceId]/+page.svelte @@ -0,0 +1,198 @@ + + +
+
+ +
+ + + +
+ + + {mobileVM.selectedDeviceDetail?.device.name || "Device"} + +
+
+ + {#if mobileVM.selectedDeviceDetail} +
+
+ External ID: +
+ {mobileVM.selectedDeviceDetail.device.externalDeviceId} +
+
+
+ Last Ping: +
+ {mobileVM.formatLastPing( + mobileVM.selectedDeviceDetail.device.lastPingAt, + )} +
+
+
+ Manufacturer: +
{mobileVM.selectedDeviceDetail.device.manufacturer}
+
+
+ Model: +
{mobileVM.selectedDeviceDetail.device.model}
+
+
+ Android: +
{mobileVM.selectedDeviceDetail.device.androidVersion}
+
+
+ Counts: +
+ SMS: {mobileVM.selectedDeviceDetail.smsCount}, Media: + {mobileVM.selectedDeviceDetail.mediaCount} +
+
+
+ {/if} +
+
+ + + + + + SMS + + + + Media Assets + + + + + + + SMS + + + {#if !mobileVM.smsLoading && mobileVM.sms.length === 0} +
+ No SMS records yet. +
+ {:else} + + + + From + To + Body + Sent + + + + {#each mobileVM.sms as message (message.id)} + + {message.sender} + + {message.recipient || "-"} + + + {message.body} + + + {new Date(message.sentAt).toLocaleString()} + + + {/each} + + + {/if} +
+
+
+ + + + + Media Assets + + + {#if !mobileVM.mediaLoading && mobileVM.media.length === 0} +
+ No media assets yet. +
+ {:else} + + + + Filename + MIME + Size + Captured + File ID + + + + {#each mobileVM.media as asset (asset.id)} + + {asset.filename || "-"} + {asset.mimeType} + + {asset.sizeBytes ? `${asset.sizeBytes} B` : "-"} + + + {asset.capturedAt + ? new Date(asset.capturedAt).toLocaleString() + : "-"} + + + {asset.fileId} + + + {/each} + + + {/if} +
+
+
+
+
diff --git a/packages/logic/domains/mobile/controller.ts b/packages/logic/domains/mobile/controller.ts index ff5b90c..45aa87c 100644 --- a/packages/logic/domains/mobile/controller.ts +++ b/packages/logic/domains/mobile/controller.ts @@ -1,4 +1,4 @@ -import { +import type { ListMobileDeviceMediaFilters, ListMobileDeviceSMSFilters, ListMobileDevicesFilters, @@ -8,13 +8,13 @@ import { PingMobileDevice, RegisterMobileDevice, } from "./data"; -import { FlowExecCtx } from "@core/flow.execution.context"; +import type { FlowExecCtx } from "@core/flow.execution.context"; import { traceResultAsync } from "@core/observability"; import { MobileRepository } from "./repository"; -import { errAsync } from "neverthrow"; -import { db } from "@pkg/db"; import { settings } from "@core/settings"; import { mobileErrors } from "./errors"; +import { errAsync } from "neverthrow"; +import { db } from "@pkg/db"; export class MobileController { constructor( @@ -24,14 +24,20 @@ export class MobileController { registerDevice(fctx: FlowExecCtx, payload: RegisterMobileDevice) { return traceResultAsync({ - name: "logic.mobile.controller.register", + name: "mobile.register", fctx, - attributes: { "app.mobile.external_device_id": payload.externalDeviceId }, + attributes: { + "app.mobile.external_device_id": payload.externalDeviceId, + }, fn: () => this.mobileRepo .findAdminOwnerId(fctx, this.defaultAdminEmail) .andThen((ownerUserId) => - this.mobileRepo.upsertDevice(fctx, payload, ownerUserId), + this.mobileRepo.upsertDevice( + fctx, + payload, + ownerUserId, + ), ), }); } @@ -42,12 +48,13 @@ export class MobileController { payload?: PingMobileDevice, ) { return traceResultAsync({ - name: "logic.mobile.controller.ping", + name: "mobile.ping", fctx, attributes: { "app.mobile.external_device_id": externalDeviceId }, fn: () => - this.mobileRepo.getDeviceByExternalId(fctx, externalDeviceId).andThen( - (device) => { + this.mobileRepo + .getDeviceByExternalId(fctx, externalDeviceId) + .andThen((device) => { const pingAt = payload?.pingAt ? new Date(payload.pingAt as Date | string) : new Date(); @@ -59,9 +66,12 @@ export class MobileController { ), ); } - return this.mobileRepo.touchDevicePing(fctx, device.id, pingAt); - }, - ), + return this.mobileRepo.touchDevicePing( + fctx, + device.id, + pingAt, + ); + }), }); } @@ -71,24 +81,27 @@ export class MobileController { messages: MobileSMSInput[], ) { return traceResultAsync({ - name: "logic.mobile.controller.sms_sync", + name: "mobile.sms.sync", fctx, attributes: { "app.mobile.external_device_id": externalDeviceId, "app.mobile.sms.received_count": messages.length, }, fn: () => - this.mobileRepo.getDeviceByExternalId(fctx, externalDeviceId).andThen( - (device) => + this.mobileRepo + .getDeviceByExternalId(fctx, externalDeviceId) + .andThen((device) => this.mobileRepo .syncSMS(fctx, device.id, messages) .andThen((syncResult) => - this.mobileRepo.touchDevicePing(fctx, device.id).map(() => ({ - ...syncResult, - deviceId: device.id, - })), + this.mobileRepo + .touchDevicePing(fctx, device.id) + .map(() => ({ + ...syncResult, + deviceId: device.id, + })), ), - ), + ), }); } @@ -98,24 +111,27 @@ export class MobileController { assets: MobileMediaAssetInput[], ) { return traceResultAsync({ - name: "logic.mobile.controller.media_sync", + name: "mobile.media.sync", fctx, attributes: { "app.mobile.external_device_id": externalDeviceId, "app.mobile.media.received_count": assets.length, }, fn: () => - this.mobileRepo.getDeviceByExternalId(fctx, externalDeviceId).andThen( - (device) => + this.mobileRepo + .getDeviceByExternalId(fctx, externalDeviceId) + .andThen((device) => this.mobileRepo .syncMediaAssets(fctx, device.id, assets) .andThen((syncResult) => - this.mobileRepo.touchDevicePing(fctx, device.id).map(() => ({ - ...syncResult, - deviceId: device.id, - })), + this.mobileRepo + .touchDevicePing(fctx, device.id) + .map(() => ({ + ...syncResult, + deviceId: device.id, + })), ), - ), + ), }); } @@ -125,7 +141,7 @@ export class MobileController { pagination: MobilePagination, ) { return traceResultAsync({ - name: "logic.mobile.controller.list_devices", + name: "mobile.devices.list", fctx, attributes: { "app.user.id": filters.ownerUserId }, fn: () => this.mobileRepo.listDevices(fctx, filters, pagination), @@ -134,10 +150,14 @@ export class MobileController { getDeviceDetail(fctx: FlowExecCtx, deviceId: number, ownerUserId: string) { return traceResultAsync({ - name: "logic.mobile.controller.get_device_detail", + name: "mobile.device.detail", fctx, - attributes: { "app.user.id": ownerUserId, "app.mobile.device_id": deviceId }, - fn: () => this.mobileRepo.getDeviceDetail(fctx, deviceId, ownerUserId), + attributes: { + "app.user.id": ownerUserId, + "app.mobile.device_id": deviceId, + }, + fn: () => + this.mobileRepo.getDeviceDetail(fctx, deviceId, ownerUserId), }); } @@ -147,7 +167,7 @@ export class MobileController { pagination: MobilePagination, ) { return traceResultAsync({ - name: "logic.mobile.controller.list_device_sms", + name: "mobile.device.sms.list", fctx, attributes: { "app.mobile.device_id": filters.deviceId }, fn: () => this.mobileRepo.listDeviceSMS(fctx, filters, pagination), @@ -160,40 +180,54 @@ export class MobileController { pagination: MobilePagination, ) { return traceResultAsync({ - name: "logic.mobile.controller.list_device_media", + name: "mobile.device.media.list", fctx, attributes: { "app.mobile.device_id": filters.deviceId }, - fn: () => this.mobileRepo.listDeviceMedia(fctx, filters, pagination), + fn: () => + this.mobileRepo.listDeviceMedia(fctx, filters, pagination), }); } - deleteMediaAsset(fctx: FlowExecCtx, mediaAssetId: number, ownerUserId: string) { + deleteMediaAsset( + fctx: FlowExecCtx, + mediaAssetId: number, + ownerUserId: string, + ) { return traceResultAsync({ - name: "logic.mobile.controller.delete_media_asset", + name: "mobile.media.delete", fctx, attributes: { "app.user.id": ownerUserId, "app.mobile.media_asset_id": mediaAssetId, }, - fn: () => this.mobileRepo.deleteMediaAsset(fctx, mediaAssetId, ownerUserId), + fn: () => + this.mobileRepo.deleteMediaAsset( + fctx, + mediaAssetId, + ownerUserId, + ), }); } deleteDevice(fctx: FlowExecCtx, deviceId: number, ownerUserId: string) { return traceResultAsync({ - name: "logic.mobile.controller.delete_device", + name: "mobile.device.delete", fctx, - attributes: { "app.user.id": ownerUserId, "app.mobile.device_id": deviceId }, + attributes: { + "app.user.id": ownerUserId, + "app.mobile.device_id": deviceId, + }, fn: () => this.mobileRepo.deleteDevice(fctx, deviceId, ownerUserId), }); } resolveDeviceByExternalId(fctx: FlowExecCtx, externalDeviceId: string) { return traceResultAsync({ - name: "logic.mobile.controller.resolve_device", + name: "mobile.device.resolve", fctx, attributes: { "app.mobile.external_device_id": externalDeviceId }, - fn: () => this.mobileRepo.getDeviceByExternalId(fctx, externalDeviceId), + fn: () => + this.mobileRepo.getDeviceByExternalId(fctx, externalDeviceId), }); } } diff --git a/spec.mobile.md b/spec.mobile.md new file mode 100644 index 0000000..e5fd69c --- /dev/null +++ b/spec.mobile.md @@ -0,0 +1,155 @@ +# Mobile Integration Spec (Stage 1) + +## Base URL + +- Processor endpoints are mounted under `/api/v1/mobile`. + +## Headers + +- Optional: `x-flow-id` for end-to-end trace correlation. +- Required for ping/sync endpoints: `x-device-id` with a previously registered `externalDeviceId`. + +## Endpoints + +### Register Device + +- Method: `POST` +- Path: `/api/v1/mobile/register` +- Auth: none (trusted private network assumption) + +Payload: + +```json +{ + "externalDeviceId": "android-1234", + "name": "Pixel 8 Pro", + "manufacturer": "Google", + "model": "Pixel 8 Pro", + "androidVersion": "15" +} +``` + +### Ping Device + +- Method: `PUT` +- Path: `/api/v1/mobile/ping` +- Required header: `x-device-id` + +Payload: + +```json +{ + "pingAt": "2026-03-01T10:15:00.000Z" +} +``` + +### Sync SMS + +- Method: `PUT` +- Path: `/api/v1/mobile/sms/sync` +- Required header: `x-device-id` + +Payload: + +```json +{ + "messages": [ + { + "externalMessageId": "msg-1", + "sender": "+358401111111", + "recipient": "+358402222222", + "body": "Hello from device", + "sentAt": "2026-03-01T10:10:00.000Z", + "receivedAt": "2026-03-01T10:10:01.000Z", + "rawPayload": { + "threadId": "7" + } + } + ] +} +``` + +### Sync Media Assets + +- Method: `PUT` +- Path: `/api/v1/mobile/media/sync` +- Required header: `x-device-id` + +Payload: + +```json +{ + "assets": [ + { + "externalMediaId": "media-1", + "fileId": "01JNE3Q1S3KQX9Y7G2J8G7R0A8", + "mimeType": "image/jpeg", + "filename": "IMG_1234.jpg", + "capturedAt": "2026-03-01T10:05:00.000Z", + "sizeBytes": 1350021, + "hash": "sha256-...", + "metadata": { + "width": 3024, + "height": 4032 + } + } + ] +} +``` + +## Response Contract + +Success: + +```json +{ + "data": {}, + "error": null +} +``` + +Failure: + +```json +{ + "data": null, + "error": { + "flowId": "uuid", + "code": "VALIDATION_ERROR", + "message": "Human message", + "description": "Actionable description", + "detail": "Technical detail" + } +} +``` + +## Admin Query Contract + +- Pagination: + - `page`: 1-based integer + - `pageSize`: integer +- Sorting: + - `sortBy`: operation-specific + - `sortOrder`: `asc` or `desc` +- Paginated response payload: + - `data`: rows + - `total`: full row count + - `page`, `pageSize`, `totalPages` + +## Dedup Rules + +- Device: + - Upsert on unique `externalDeviceId`. +- SMS: + - Dedup key #1: `(deviceId, externalMessageId)` when provided. + - Dedup key #2 fallback: `(deviceId, dedupHash)` where dedup hash is SHA-256 of `(deviceId + sentAt + sender + body)`. +- Media: + - Dedup key: `(deviceId, externalMediaId)` when provided. + - `fileId` in `mobile_media_asset` is unique. + +## Operator Checklist + +1. Register a device. +2. Send ping with `x-device-id` and verify dashboard `lastPingAt` updates. +3. Sync SMS and verify device detail `SMS` tab updates (polling every 5s). +4. Sync media and verify device detail `Media Assets` tab displays rows.