diff --git a/.env.example b/.env.example index c770b24..22a5a71 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,11 @@ NEXTAUTH_URL=http://localhost:3000 # NEXTAUTH_URL) — webhooks don't fire into localhost dev. MOLLIE_API_KEY= +# --- Fleet telemetry roll-up (POST /api/telemetry/fleet) --- +# Shared bearer secret each tenant backend's FleetSummaryPushService must present +# (openssl rand -hex 32). Must match the same secret on the backend side. Unset -> the route 503s. +PRINTER_TELEMETRY_SECRET= + # --- Data retention sweep (GDPR storage-limitation; POST /api/cron/retention) --- # Shared bearer secret the cron caller must present (openssl rand -hex 32). Must # match the CRON_SECRET GitHub secret on piwas-21/sofra. Unset -> the route 503s. diff --git a/app/(control)/admin/fleet/page.tsx b/app/(control)/admin/fleet/page.tsx new file mode 100644 index 0000000..288d750 --- /dev/null +++ b/app/(control)/admin/fleet/page.tsx @@ -0,0 +1,181 @@ +import { getTranslations } from "next-intl/server"; +import { requireAdmin } from "@/lib/rbac"; +import { controlLocale } from "@/lib/control-locale"; +import { db } from "@/lib/db"; +import { loadTenantRegistry, type RegistryTenant } from "@/lib/tenant-registry"; + +// The registry file changes underneath us (rsync on deploy-repo push) — always re-read. +export const dynamic = "force-dynamic"; + +// Presentation-layer liveness thresholds (the backend stores raw facts; "online"/"stale" is derived +// here, per the fleet plan). A device heartbeats ~every 60s and the backend rolls up every few min. +const ONLINE_WINDOW_MS = 5 * 60 * 1000; // no heartbeat in 5 min ⇒ offline +const STALE_FEED_MS = 5 * 60 * 1000; // feed "running" but no successful poll in 5 min ⇒ wedged + +type Translator = (key: string, values?: Record) => string; + +type FleetDeviceRow = { + deviceId: string; + label: string | null; + platform: string | null; + appVersion: string | null; + feedRunning: boolean; + lastHeartbeatAt: Date | null; + lastSuccessfulPollAt: Date | null; +}; + +type FleetSummaryRow = { missedOrders: number; recentErrors: number }; + +function livenessLabel(device: FleetDeviceRow, now: number, t: Translator): { label: string; online: boolean } { + const online = device.lastHeartbeatAt != null && now - device.lastHeartbeatAt.getTime() < ONLINE_WINDOW_MS; + if (online) return { label: t("device.online"), online }; + if (device.lastHeartbeatAt == null) return { label: t("device.neverSeen"), online }; + const minutes = Math.max(0, Math.round((now - device.lastHeartbeatAt.getTime()) / 60000)); + return { label: t("device.offlineSince", { minutes }), online }; +} + +function feedStatus(device: FleetDeviceRow, now: number, t: Translator): { label: string; ok: boolean } { + const stale = + device.feedRunning && + (device.lastSuccessfulPollAt == null || now - device.lastSuccessfulPollAt.getTime() > STALE_FEED_MS); + if (!device.feedRunning) return { label: t("device.feedStopped"), ok: false }; + if (stale) return { label: t("device.feedStale"), ok: false }; + return { label: t("device.feedRunning"), ok: true }; +} + +function DeviceRow({ device, now, t }: Readonly<{ device: FleetDeviceRow; now: number; t: Translator }>) { + const liveness = livenessLabel(device, now, t); + const feed = feedStatus(device, now, t); + const feedTone = feed.ok + ? "text-craft-success-text dark:text-craft-success" + : "text-craft-error-text dark:text-craft-error"; + + return ( +
  • + + {device.label ?? device.deviceId.slice(0, 8)} + + {device.platform ?? "—"} · v{device.appVersion ?? "—"} + + + + + {liveness.label} + + {feed.label} + +
  • + ); +} + +function TenantCard({ + tenant, + devices, + summary, + now, + t, +}: Readonly<{ + tenant: RegistryTenant; + devices: FleetDeviceRow[]; + summary?: FleetSummaryRow; + now: number; + t: Translator; +}>) { + return ( +
  • +
    + + {tenant.slug} + + {tenant.name} + {tenant.city ? ` · ${tenant.city}` : ""} + + + {summary && ( + + {t("summary.missed", { count: summary.missedOrders })} ·{" "} + {t("summary.errors", { count: summary.recentErrors })} + + )} +
    + + {devices.length === 0 ? ( +

    {t("tenant.noReport")}

    + ) : ( + + )} +
  • + ); +} + +function FleetHeader({ t }: Readonly<{ t: Translator }>) { + return ( +
    +

    {t("title")}

    +

    {t("intro")}

    +
    + ); +} + +export default async function AdminFleetPage() { + await requireAdmin(); + const locale = await controlLocale(); + const t = await getTranslations({ locale, namespace: "control.admin.fleet" }); + const registry = await loadTenantRegistry(); + + // Registry unavailable ⇒ bail before the DB reads (nothing to join to). + if (!registry.ok) { + return ( +
    + +

    + {t("unavailable", { error: registry.error })} +

    +
    + ); + } + + const [devices, summaries] = await Promise.all([ + db.fleetDevice.findMany({ orderBy: [{ tenantSlug: "asc" }, { label: "asc" }] }), + db.fleetSummary.findMany(), + ]); + + const devicesBySlug = new Map(); + for (const d of devices) { + const list = devicesBySlug.get(d.tenantSlug) ?? []; + list.push(d); + devicesBySlug.set(d.tenantSlug, list); + } + const summaryBySlug = new Map(summaries.map((s) => [s.tenantSlug, s])); + const now = Date.now(); + + return ( +
    + +
    +

    + {t("registered", { count: registry.tenants.length })} +

    +
      + {registry.tenants.map((tenant) => ( + + ))} + {registry.tenants.length === 0 && ( +
    • {t("empty")}
    • + )} +
    +
    +
    + ); +} diff --git a/app/(control)/admin/layout.tsx b/app/(control)/admin/layout.tsx index 336d255..f0741a7 100644 --- a/app/(control)/admin/layout.tsx +++ b/app/(control)/admin/layout.tsx @@ -19,6 +19,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN { href: "/admin/partners", label: t("nav.partners") }, { href: "/admin/clients", label: t("nav.clients") }, { href: "/admin/tenants", label: t("nav.tenants") }, + { href: "/admin/fleet", label: t("nav.fleet") }, { href: "/admin/provision", label: t("nav.provision") }, { href: "/admin/onboard", label: t("nav.onboard") }, { href: "/admin/billing", label: t("nav.billing") }, diff --git a/app/api/telemetry/fleet/route.ts b/app/api/telemetry/fleet/route.ts new file mode 100644 index 0000000..8c2d852 --- /dev/null +++ b/app/api/telemetry/fleet/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { createHash, timingSafeEqual } from "node:crypto"; +import { fleetPushSchema, ingestFleetPush } from "@/lib/fleet"; + +// Machine-to-machine ingest for the fleet roll-up: each tenant backend's FleetSummaryPushService +// POSTs a compact per-tenant snapshot here. NOT a (control) surface — no RBAC session; guarded by a +// shared PRINTER_TELEMETRY_SECRET bearer token. One-directional (backend → sofra): devices/backends +// hold no sofra credential, sofra holds no backend credential. Non-PII (schema-enforced). +// +// TRUST MODEL (follow-up): the bearer is a single shared machine secret and the tenant is taken from +// the body's tenantSlug, so any secret-holder could write/prune another tenant's rows. Blast radius +// is bounded — internal admin-only view, non-PII, self-heals on the next legitimate push, one tenant +// today. When the backend push slice lands, bind the slug to a per-tenant credential. + +// Constant-time bearer check. Both sides are SHA-256'd to a fixed 32 bytes first so timingSafeEqual +// never sees a length mismatch (which would throw and leak the secret's length via timing). +function authorized(request: Request): boolean { + const secret = process.env.PRINTER_TELEMETRY_SECRET; + if (!secret) return false; + const digest = (s: string) => createHash("sha256").update(s).digest(); + const provided = digest(request.headers.get("authorization") ?? ""); + const expected = digest(`Bearer ${secret}`); + return timingSafeEqual(provided, expected); +} + +export async function POST(request: Request) { + if (!process.env.PRINTER_TELEMETRY_SECRET) { + return NextResponse.json({ error: "fleet telemetry not configured" }, { status: 503 }); + } + if (!authorized(request)) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "invalid json" }, { status: 400 }); + } + + const parsed = fleetPushSchema.safeParse(body); + if (!parsed.success) { + // Never echo the payload — just that it was malformed. + return NextResponse.json({ error: "invalid payload" }, { status: 400 }); + } + + await ingestFleetPush(parsed.data); + return NextResponse.json({ ok: true, devices: parsed.data.devices.length }); +} diff --git a/lib/fleet.ts b/lib/fleet.ts new file mode 100644 index 0000000..91d232b --- /dev/null +++ b/lib/fleet.ts @@ -0,0 +1,94 @@ +import { z } from "zod"; +import { db } from "@/lib/db"; + +// Payload pushed by a tenant backend's FleetSummaryPushService (one-directional, bearer-authed). +// Non-PII only: device roster + counts, never customer data / API keys / raw orders. +export const fleetDeviceSchema = z.object({ + deviceId: z.string().trim().min(1).max(64), + label: z.string().trim().max(120).nullish(), + platform: z.string().trim().max(40).nullish(), + appVersion: z.string().trim().max(40).nullish(), + feedRunning: z.boolean().default(false), + lastHeartbeatAt: z.coerce.date().nullish(), + lastSuccessfulPollAt: z.coerce.date().nullish(), + apiBaseUrl: z.string().trim().max(300).nullish(), + kitchenPrinter: z.string().trim().max(200).nullish(), + cashierPrinter: z.string().trim().max(200).nullish(), +}); + +export const fleetPushSchema = z.object({ + tenantSlug: z.string().trim().min(1).max(80), + reportedAt: z.coerce.date(), + missedOrders: z.number().int().min(0).max(1_000_000).default(0), + recentErrors: z.number().int().min(0).max(1_000_000).default(0), + devices: z.array(fleetDeviceSchema).max(200), +}); + +export type FleetPush = z.infer; +type FleetDeviceInput = z.infer; + +function deviceColumns(d: FleetDeviceInput) { + return { + label: d.label ?? null, + platform: d.platform ?? null, + appVersion: d.appVersion ?? null, + feedRunning: d.feedRunning, + lastHeartbeatAt: d.lastHeartbeatAt ?? null, + lastSuccessfulPollAt: d.lastSuccessfulPollAt ?? null, + apiBaseUrl: d.apiBaseUrl ?? null, + kitchenPrinter: d.kitchenPrinter ?? null, + cashierPrinter: d.cashierPrinter ?? null, + }; +} + +// Upsert the tenant's summary + reconcile its device roster (upsert reported, prune the rest) in +// one transaction, so a partial failure can't leave a half-updated fleet view. +export async function ingestFleetPush(push: FleetPush): Promise { + const slug = push.tenantSlug; + const now = new Date(); + // Dedup by deviceId (a repeated id in one push would upsert the same row twice) and sort + // deterministically, so concurrent same-tenant pushes acquire row locks in the same order and + // can't deadlock. A later duplicate wins (Map keeps the last value for a key). + const devices = Array.from(new Map(push.devices.map((d) => [d.deviceId, d])).values()).sort((a, b) => + a.deviceId.localeCompare(b.deviceId), + ); + const reportedIds = devices.map((d) => d.deviceId); + + await db.$transaction(async (tx) => { + await tx.fleetSummary.upsert({ + where: { tenantSlug: slug }, + create: { + tenantSlug: slug, + deviceCount: devices.length, + missedOrders: push.missedOrders, + recentErrors: push.recentErrors, + reportedAt: push.reportedAt, + receivedAt: now, + }, + update: { + deviceCount: devices.length, + missedOrders: push.missedOrders, + recentErrors: push.recentErrors, + reportedAt: push.reportedAt, + receivedAt: now, + }, + }); + + // Prune devices this tenant no longer reports (decommissioned) — scoped to THIS tenant only. + // An empty roster drops them all; otherwise keep only the reported ids. + if (reportedIds.length === 0) { + await tx.fleetDevice.deleteMany({ where: { tenantSlug: slug } }); + } else { + await tx.fleetDevice.deleteMany({ where: { tenantSlug: slug, deviceId: { notIn: reportedIds } } }); + } + + for (const d of devices) { + const columns = deviceColumns(d); + await tx.fleetDevice.upsert({ + where: { tenantSlug_deviceId: { tenantSlug: slug, deviceId: d.deviceId } }, + create: { tenantSlug: slug, deviceId: d.deviceId, ...columns }, + update: columns, + }); + } + }); +} diff --git a/messages/ar.json b/messages/ar.json index a34f9c5..ad0c775 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -468,7 +468,8 @@ "onboard": "الإعداد", "plan": "الاشتراك", "overview": "نظرة عامة", - "signups": "التسجيلات" + "signups": "التسجيلات", + "fleet": "الأسطول" } }, "status": { @@ -726,6 +727,28 @@ "creating": "جارٍ الفتح…", "created": "تم فتح طلب سحب للسجل — راجعه وادمجه، ثم زوّد:", "nextSteps": "بعد الدمج: تتزامن التغييرات مع الخادم في المزامنة التالية للنشر، ثم شغّل إجراء provision-tenant بهذا المعرّف." + }, + "fleet": { + "title": "أسطول الطابعات", + "intro": "الحالة المباشرة لتطبيقات الطباعة لكل مستأجر — نشاط الأجهزة، وتدفق الطلبات، والطلبات الفائتة.", + "unavailable": "سجل المستأجرين غير متاح: {error}", + "registered": "{count} مستأجرين", + "empty": "لا يوجد مستأجرون مسجلون بعد.", + "tenant": { + "noReport": "لم تُبلّغ أي أجهزة بعد." + }, + "device": { + "online": "متصل", + "offlineSince": "غير متصل · قبل {minutes} دقيقة", + "neverSeen": "لم يُشاهد قط", + "feedRunning": "التدفق يعمل", + "feedStopped": "التدفق متوقف", + "feedStale": "التدفق معلّق" + }, + "summary": { + "missed": "{count} فائتة", + "errors": "{count} أخطاء" + } } }, "errors": { diff --git a/messages/de.json b/messages/de.json index 72084aa..c15b642 100644 --- a/messages/de.json +++ b/messages/de.json @@ -468,7 +468,8 @@ "onboard": "Onboarding", "plan": "Abo", "overview": "Übersicht", - "signups": "Anmeldungen" + "signups": "Anmeldungen", + "fleet": "Flotte" } }, "status": { @@ -726,6 +727,28 @@ "creating": "Wird geöffnet…", "created": "Registry-PR geöffnet — prüfen + zusammenführen, dann bereitstellen:", "nextSteps": "Nach dem Zusammenführen: Die Änderung wird beim nächsten Deploy-Sync auf den Server übertragen, dann die provision-tenant-Action mit diesem Slug starten." + }, + "fleet": { + "title": "Drucker-Flotte", + "intro": "Live-Status der Drucker-Apps jedes Mandanten — Geräteaktivität, Bestell-Feed und verpasste Bestellungen.", + "unavailable": "Mandantenregister nicht verfügbar: {error}", + "registered": "{count} Mandanten", + "empty": "Noch keine Mandanten registriert.", + "tenant": { + "noReport": "Noch keine Geräte gemeldet." + }, + "device": { + "online": "Online", + "offlineSince": "Offline · vor {minutes} Min.", + "neverSeen": "Nie gesehen", + "feedRunning": "Feed läuft", + "feedStopped": "Feed gestoppt", + "feedStale": "Feed hängt" + }, + "summary": { + "missed": "{count} verpasst", + "errors": "{count} Fehler" + } } }, "errors": { diff --git a/messages/en.json b/messages/en.json index 8ecc1b3..547ee11 100644 --- a/messages/en.json +++ b/messages/en.json @@ -468,7 +468,8 @@ "onboard": "Onboard", "plan": "Plan", "overview": "Overview", - "signups": "Signups" + "signups": "Signups", + "fleet": "Fleet" } }, "status": { @@ -726,6 +727,28 @@ "creating": "Opening…", "created": "Registry PR opened — review + merge it, then provision:", "nextSteps": "After merge: the change syncs to the box on the next deploy sync, then run the provision-tenant Action with this slug." + }, + "fleet": { + "title": "Printer fleet", + "intro": "Live status of every tenant's printer apps — device liveness, order feed, and missed orders.", + "unavailable": "Tenant registry unavailable: {error}", + "registered": "{count} tenants", + "empty": "No tenants registered yet.", + "tenant": { + "noReport": "No devices have reported yet." + }, + "device": { + "online": "Online", + "offlineSince": "Offline · {minutes}m ago", + "neverSeen": "Never seen", + "feedRunning": "Feed running", + "feedStopped": "Feed stopped", + "feedStale": "Feed stalled" + }, + "summary": { + "missed": "{count} missed", + "errors": "{count} errors" + } } }, "errors": { diff --git a/messages/fr.json b/messages/fr.json index dd216fc..eb6e2f1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -468,7 +468,8 @@ "onboard": "Intégrer", "plan": "Abonnement", "overview": "Aperçu", - "signups": "Inscriptions" + "signups": "Inscriptions", + "fleet": "Parc" } }, "status": { @@ -726,6 +727,28 @@ "creating": "Ouverture…", "created": "PR de registre ouverte — révisez + fusionnez, puis provisionnez :", "nextSteps": "Après la fusion : la modification se synchronise avec le serveur lors de la prochaine synchro de déploiement, puis lancez l’action provision-tenant avec cet identifiant." + }, + "fleet": { + "title": "Parc d'imprimantes", + "intro": "État en direct des applications d'impression de chaque locataire — activité des appareils, flux de commandes et commandes manquées.", + "unavailable": "Registre des locataires indisponible : {error}", + "registered": "{count} locataires", + "empty": "Aucun locataire enregistré pour l'instant.", + "tenant": { + "noReport": "Aucun appareil n'a encore signalé." + }, + "device": { + "online": "En ligne", + "offlineSince": "Hors ligne · il y a {minutes} min", + "neverSeen": "Jamais vu", + "feedRunning": "Flux actif", + "feedStopped": "Flux arrêté", + "feedStale": "Flux bloqué" + }, + "summary": { + "missed": "{count} manquées", + "errors": "{count} erreurs" + } } }, "errors": { diff --git a/messages/nl.json b/messages/nl.json index 74d0823..73dcdeb 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -468,7 +468,8 @@ "onboard": "Onboarden", "plan": "Abonnement", "overview": "Overzicht", - "signups": "Aanmeldingen" + "signups": "Aanmeldingen", + "fleet": "Vloot" } }, "status": { @@ -726,6 +727,28 @@ "creating": "Bezig met openen…", "created": "Registry-PR geopend — beoordeel + voeg samen, richt dan in:", "nextSteps": "Na samenvoegen: de wijziging synchroniseert naar de server bij de volgende deploy-sync, start dan de provision-tenant Action met deze slug." + }, + "fleet": { + "title": "Printervloot", + "intro": "Live-status van de printer-apps van elke tenant — apparaatactiviteit, bestelfeed en gemiste bestellingen.", + "unavailable": "Tenantregister niet beschikbaar: {error}", + "registered": "{count} tenants", + "empty": "Nog geen tenants geregistreerd.", + "tenant": { + "noReport": "Nog geen apparaten gemeld." + }, + "device": { + "online": "Online", + "offlineSince": "Offline · {minutes} min geleden", + "neverSeen": "Nooit gezien", + "feedRunning": "Feed actief", + "feedStopped": "Feed gestopt", + "feedStale": "Feed vastgelopen" + }, + "summary": { + "missed": "{count} gemist", + "errors": "{count} fouten" + } } }, "errors": { diff --git a/messages/tr.json b/messages/tr.json index 83c5809..5dfc7e4 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -468,7 +468,8 @@ "onboard": "Katılım", "plan": "Abonelik", "overview": "Genel bakış", - "signups": "Kayıtlar" + "signups": "Kayıtlar", + "fleet": "Filo" } }, "status": { @@ -726,6 +727,28 @@ "creating": "Açılıyor…", "created": "Kayıt PR’si açıldı — inceleyip birleştirin, sonra sağlayın:", "nextSteps": "Birleştirmeden sonra: değişiklik bir sonraki dağıtım eşitlemesinde sunucuya eşitlenir, ardından bu kısa adla provision-tenant Action’ını çalıştırın." + }, + "fleet": { + "title": "Yazıcı filosu", + "intro": "Her kiracının yazıcı uygulamalarının canlı durumu — cihaz etkinliği, sipariş akışı ve kaçırılan siparişler.", + "unavailable": "Kiracı kaydı kullanılamıyor: {error}", + "registered": "{count} kiracı", + "empty": "Henüz kayıtlı kiracı yok.", + "tenant": { + "noReport": "Henüz cihaz bildirmedi." + }, + "device": { + "online": "Çevrimiçi", + "offlineSince": "Çevrimdışı · {minutes} dk önce", + "neverSeen": "Hiç görülmedi", + "feedRunning": "Akış çalışıyor", + "feedStopped": "Akış durdu", + "feedStale": "Akış takıldı" + }, + "summary": { + "missed": "{count} kaçırıldı", + "errors": "{count} hata" + } } }, "errors": { diff --git a/prisma/migrations/20260720013000_fleet_observability/migration.sql b/prisma/migrations/20260720013000_fleet_observability/migration.sql new file mode 100644 index 0000000..12b0a56 --- /dev/null +++ b/prisma/migrations/20260720013000_fleet_observability/migration.sql @@ -0,0 +1,39 @@ +-- Fleet observability roll-up (docs/plans/PRINTER-APP-FLEET-OBSERVABILITY-PLAN.md). +-- Additive: two new tables that receive a one-directional, bearer-authed per-tenant +-- snapshot from each tenant backend. Keyed on the registry slug (not a FK). Non-PII. + +-- Per-tenant summary (one row per tenant, upserted on each push). +CREATE TABLE "FleetSummary" ( + "id" TEXT NOT NULL, + "tenantSlug" TEXT NOT NULL, + "deviceCount" INTEGER NOT NULL DEFAULT 0, + "missedOrders" INTEGER NOT NULL DEFAULT 0, + "recentErrors" INTEGER NOT NULL DEFAULT 0, + "reportedAt" TIMESTAMP(3) NOT NULL, + "receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "FleetSummary_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "FleetSummary_tenantSlug_key" ON "FleetSummary"("tenantSlug"); + +-- Per-device roster row (one per tenant+device; upserted, stale devices pruned on push). +CREATE TABLE "FleetDevice" ( + "id" TEXT NOT NULL, + "tenantSlug" TEXT NOT NULL, + "deviceId" TEXT NOT NULL, + "label" TEXT, + "platform" TEXT, + "appVersion" TEXT, + "feedRunning" BOOLEAN NOT NULL DEFAULT false, + "lastHeartbeatAt" TIMESTAMP(3), + "lastSuccessfulPollAt" TIMESTAMP(3), + "apiBaseUrl" TEXT, + "kitchenPrinter" TEXT, + "cashierPrinter" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "FleetDevice_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "FleetDevice_tenantSlug_deviceId_key" ON "FleetDevice"("tenantSlug", "deviceId"); +CREATE INDEX "FleetDevice_tenantSlug_idx" ON "FleetDevice"("tenantSlug"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9413c6b..05471b5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -276,3 +276,39 @@ model AuditLog { @@index([createdAt]) } + +// Fleet observability roll-up (Track S; docs/plans/PRINTER-APP-FLEET-OBSERVABILITY-PLAN.md). +// A compact per-tenant snapshot pushed one-directionally by each tenant backend's +// FleetSummaryPushService (bearer-authed — devices/backends hold no sofra credential, sofra +// holds no backend credential). Keyed on the registry slug, NOT a FK (same rationale as +// TenantBilling.tenantSlug — the registry graduates to a table only at >3 tenants). Non-PII: +// no customer data, no API keys, no raw orders — only device roster + counts. +model FleetSummary { + id String @id @default(cuid()) + tenantSlug String @unique + deviceCount Int @default(0) + missedOrders Int @default(0) + recentErrors Int @default(0) + reportedAt DateTime // the backend's snapshot time + receivedAt DateTime @default(now()) // sofra ingest time + updatedAt DateTime @updatedAt +} + +model FleetDevice { + id String @id @default(cuid()) + tenantSlug String + deviceId String + label String? + platform String? + appVersion String? + feedRunning Boolean @default(false) + lastHeartbeatAt DateTime? + lastSuccessfulPollAt DateTime? + apiBaseUrl String? + kitchenPrinter String? + cashierPrinter String? + updatedAt DateTime @updatedAt + + @@unique([tenantSlug, deviceId]) + @@index([tenantSlug]) +} diff --git a/tests/unit/fleet.test.ts b/tests/unit/fleet.test.ts new file mode 100644 index 0000000..09fbd53 --- /dev/null +++ b/tests/unit/fleet.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { fleetPushSchema } from "@/lib/fleet"; + +describe("fleetPushSchema (backend → sofra roll-up)", () => { + const valid = { + tenantSlug: "rumi", + reportedAt: "2026-07-20T10:00:00.000Z", + missedOrders: 1, + recentErrors: 2, + devices: [ + { + deviceId: "dev-abc", + label: "Kitchen tablet", + platform: "Android", + appVersion: "1.0.20", + feedRunning: true, + lastHeartbeatAt: "2026-07-20T10:00:00.000Z", + lastSuccessfulPollAt: "2026-07-20T09:59:00.000Z", + apiBaseUrl: "https://www.rumirestaurant.ch", + kitchenPrinter: "192.168.1.50", + cashierPrinter: "192.168.1.51", + }, + ], + }; + + it("accepts a well-formed push and coerces ISO dates", () => { + const parsed = fleetPushSchema.parse(valid); + expect(parsed.tenantSlug).toBe("rumi"); + expect(parsed.reportedAt).toBeInstanceOf(Date); + expect(parsed.devices[0].lastHeartbeatAt).toBeInstanceOf(Date); + expect(parsed.devices[0].feedRunning).toBe(true); + }); + + it("defaults counts and feedRunning, allows an empty roster", () => { + const parsed = fleetPushSchema.parse({ + tenantSlug: "rumi", + reportedAt: valid.reportedAt, + devices: [{ deviceId: "dev-1" }], + }); + expect(parsed.missedOrders).toBe(0); + expect(parsed.recentErrors).toBe(0); + expect(parsed.devices[0].feedRunning).toBe(false); + + const empty = fleetPushSchema.parse({ tenantSlug: "rumi", reportedAt: valid.reportedAt, devices: [] }); + expect(empty.devices).toHaveLength(0); + }); + + it("rejects a missing tenant slug, missing device id, and negative counts", () => { + expect(fleetPushSchema.safeParse({ ...valid, tenantSlug: "" }).success).toBe(false); + expect(fleetPushSchema.safeParse({ ...valid, missedOrders: -1 }).success).toBe(false); + expect( + fleetPushSchema.safeParse({ ...valid, devices: [{ deviceId: "" }] }).success, + ).toBe(false); + }); + + it("rejects an over-long device id (guards the DB column)", () => { + const parsed = fleetPushSchema.safeParse({ + ...valid, + devices: [{ deviceId: "x".repeat(65) }], + }); + expect(parsed.success).toBe(false); + }); +});