# MDM Healthcheck Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ein Nitro-Cron-Healthcheck prüft alle 5 Minuten alle über `UserDevice.mdmId` mit NanoMDM verknüpften iOS-Geräte und persistiert Enrollment-, Supervision-Status sowie letzten Check-In auf `UserDevice`. **Architecture:** Ein neues Nitro-Plugin orchestriert den Lauf. `backend/server/db/mdm.ts` bekommt Bulk-Lesefunktionen für NanoMDM und ein Update für `UserDevice`. Die benötigten Spalten werden per Prisma-Migration auf `UserDevice` ergänzt. Keine neue Tabelle. **Tech Stack:** Nuxt/Nitro, Prisma, PostgreSQL (NanoMDM), TypeScript, Vitest. --- ## Files - `backend/prisma/schema.prisma` — `UserDevice` um `mdmEnrolled`, `mdmSupervised`, `mdmLastSeenAt` erweitern - `backend/prisma/migrations/20250618_add_mdm_health_columns/migration.sql` — idempotente Migration - `backend/server/db/mdm.ts` — Bulk-Abfrage von NanoMDM, Update der UserDevice-Health-Spalten - `backend/server/plugins/mdm-health-cron.ts` — 5-Minuten-Cron - `backend/tests/devices/mdm-health.test.ts` — Unit-Tests für DB-Layer und Cron-Logik --- ### Task 1: Extend UserDevice Prisma schema **Files:** - Modify: `backend/prisma/schema.prisma:1111-1114` - [ ] **Step 1: Add three new fields after `mdmId`** ```prisma // ─── NanoMDM iOS Enrollment ───────────────────────────────────────────── /// Apple-Geräte-UDID wie von NanoMDM verwendet (z.B. 00008101-000544261E87001E). /// NULL → Gerät ist nicht mit einem MDM-UDID verknüpft. mdmId String? @map("mdm_id") /// Gespiegelter Enrollment-Status aus NanoMDM enrollments.enabled. mdmEnrolled Boolean? @map("mdm_enrolled") /// Gespiegelter Supervision-Status aus NanoMDM devices.unlock_token IS NOT NULL. mdmSupervised Boolean? @map("mdm_supervised") /// Letzter NanoMDM Check-In (enrollments.last_seen_at). mdmLastSeenAt DateTime? @map("mdm_last_seen_at") @db.Timestamptz(6) ``` - [ ] **Step 2: Validate schema** Run: `cd backend && pnpm prisma validate` Expected: `Prisma schema validation - (get config )` - [ ] **Step 3: Commit** ```bash git add backend/prisma/schema.prisma git commit -m "feat(mdm): add mdmEnrolled, mdmSupervised, mdmLastSeenAt to UserDevice schema" ``` --- ### Task 2: Create migration for MDM health columns **Files:** - Create: `backend/prisma/migrations/20250618_add_mdm_health_columns/migration.sql` - [ ] **Step 1: Write idempotent migration** ```sql -- Spiegel-Spalten für NanoMDM Enrollment/Supervision-Status auf UserDevice. -- mdm_id wurde manuell hinzugefügt; IF NOT EXISTS macht die Migration idempotent. ALTER TABLE "rebreak"."user_devices" ADD COLUMN IF NOT EXISTS "mdm_id" TEXT, ADD COLUMN IF NOT EXISTS "mdm_enrolled" BOOLEAN, ADD COLUMN IF NOT EXISTS "mdm_supervised" BOOLEAN, ADD COLUMN IF NOT EXISTS "mdm_last_seen_at" TIMESTAMPTZ(6); CREATE INDEX IF NOT EXISTS "user_devices_mdm_id_idx" ON "rebreak"."user_devices"("mdm_id"); ``` - [ ] **Step 2: Apply migration locally** Run: `cd backend && pnpm prisma migrate dev --name add_mdm_health_columns` Expected: Migration applies successfully; Prisma Client is regenerated. - [ ] **Step 3: Commit** ```bash git add backend/prisma/migrations/20250618_add_mdm_health_columns git commit -m "feat(mdm): add migration for UserDevice MDM health columns" ``` --- ### Task 3: Extend mdm.ts DB layer **Files:** - Modify: `backend/server/db/mdm.ts` - [ ] **Step 1: Add types and select constant after `MdmDeviceStatus`** ```typescript export interface MdmEnrollmentStatus { enrolled: boolean; supervised: boolean; lastSeenAt: Date | null; } export interface UserDeviceMdmHealthRecord { id: string; userId: string; deviceId: string; platform: string; mdmId: string; mdmEnrolled: boolean | null; mdmSupervised: boolean | null; mdmLastSeenAt: Date | null; } const USER_DEVICE_MDM_HEALTH_SELECT = { id: true, userId: true, deviceId: true, platform: true, mdmId: true, mdmEnrolled: true, mdmSupervised: true, mdmLastSeenAt: true, } as const; ``` - [ ] **Step 2: Add `getLinkedUserDevices`** ```typescript /** * Load all iOS devices that have a NanoMDM UDID link. */ export async function getLinkedUserDevices(): Promise { const db = usePrisma(); return db.userDevice.findMany({ where: { platform: "ios", mdmId: { not: null } }, select: USER_DEVICE_MDM_HEALTH_SELECT, }) as Promise; } ``` - [ ] **Step 3: Add `getMdmEnrollmentStatusesByUdids`** ```typescript /** * Bulk-query NanoMDM for enrollment/supervision/last-seen status. * Returns a map keyed by UDID. Missing devices are omitted. */ export async function getMdmEnrollmentStatusesByUdids( udids: string[], ): Promise> { if (udids.length === 0) { return new Map(); } const pool = useMdmPool(); const result = await pool.query<{ udid: string; enrolled: boolean; supervised: boolean; last_seen_at: Date | null; }>( `SELECT d.id AS udid, (e.enabled = TRUE) AS enrolled, (d.unlock_token IS NOT NULL) AS supervised, e.last_seen_at FROM devices d LEFT JOIN enrollments e ON e.device_id = d.id WHERE d.id = ANY($1::text[])`, [udids], ); const map = new Map(); for (const row of result.rows) { map.set(row.udid, { enrolled: row.enrolled, supervised: row.supervised, lastSeenAt: row.last_seen_at, }); } return map; } ``` - [ ] **Step 4: Add `updateUserDeviceMdmHealth`** ```typescript /** * Persist mirrored MDM health status on a UserDevice row. */ export async function updateUserDeviceMdmHealth( id: string, status: MdmEnrollmentStatus, ): Promise { const db = usePrisma(); await db.userDevice.update({ where: { id }, data: { mdmEnrolled: status.enrolled, mdmSupervised: status.supervised, mdmLastSeenAt: status.lastSeenAt, }, }); } ``` - [ ] **Step 5: Run typecheck** Run: `cd backend && pnpm typecheck` Expected: No type errors. - [ ] **Step 6: Commit** ```bash git add backend/server/db/mdm.ts git commit -m "feat(mdm): add bulk MDM health status read/write helpers" ``` --- ### Task 4: Create mdm-health-cron.ts plugin **Files:** - Create: `backend/server/plugins/mdm-health-cron.ts` - [ ] **Step 1: Write the cron plugin** ```typescript /** * MDM Healthcheck Cron * * Läuft alle 5 Minuten. Prüft für alle mit NanoMDM verknüpften iOS-Geräte * den aktuellen Enrollment-/Supervision-Status und spiegelt ihn auf UserDevice. */ import { consola } from "consola"; import { getLinkedUserDevices, getMdmEnrollmentStatusesByUdids, updateUserDeviceMdmHealth, type MdmEnrollmentStatus, } from "../db/mdm"; const FIVE_MINUTES = 5 * 60 * 1000; const INITIAL_DELAY_MS = 30 * 1000; export default defineNitroPlugin((nitro) => { if (import.meta.dev) { consola.info("[mdm-health-cron] Skipping cron in dev mode"); return; } consola.info("[mdm-health-cron] Starting (5min interval)"); const initialTimer = setTimeout(() => { runMdmHealthCheck().catch(() => {}); }, INITIAL_DELAY_MS); const interval = setInterval(() => { runMdmHealthCheck().catch(() => {}); }, FIVE_MINUTES); nitro.hooks.hook("close", () => { clearTimeout(initialTimer); clearInterval(interval); }); }); async function runMdmHealthCheck() { const start = Date.now(); try { const devices = await getLinkedUserDevices(); if (devices.length === 0) { consola.info("[mdm-health-cron] No linked iOS devices"); return; } const statuses = await getMdmEnrollmentStatusesByUdids( devices.map((d) => d.mdmId), ); let updated = 0; let unchanged = 0; for (const device of devices) { const status = statuses.get(device.mdmId) ?? { enrolled: false, supervised: false, lastSeenAt: null, }; const changed = device.mdmEnrolled !== status.enrolled || device.mdmSupervised !== status.supervised || !sameNullableDate(device.mdmLastSeenAt, status.lastSeenAt); if (changed) { await updateUserDeviceMdmHealth(device.id, status); updated++; } else { unchanged++; } } consola.success( `[mdm-health-cron] Checked ${devices.length} devices in ${Date.now() - start}ms (${updated} updated, ${unchanged} unchanged)`, ); } catch (err: any) { consola.error("[mdm-health-cron] run failed:", err?.message ?? err); } } function sameNullableDate(a: Date | null, b: Date | null): boolean { if (a === null && b === null) return true; if (a === null || b === null) return false; return a.getTime() === b.getTime(); } ``` - [ ] **Step 2: Typecheck** Run: `cd backend && pnpm typecheck` Expected: No errors. - [ ] **Step 3: Commit** ```bash git add backend/server/plugins/mdm-health-cron.ts git commit -m "feat(mdm): add 5-minute MDM healthcheck cron" ``` --- ### Task 5: Write tests for MDM healthcheck **Files:** - Create: `backend/tests/devices/mdm-health.test.ts` - [ ] **Step 1: Write tests for DB helpers** ```typescript /** * Tests für MDM-Healthcheck DB-Layer. */ import { describe, expect, it, vi, beforeEach } from "vitest"; import { getLinkedUserDevices, getMdmEnrollmentStatusesByUdids, updateUserDeviceMdmHealth, } from "../../server/db/mdm"; const mockPrisma = { userDevice: { findMany: vi.fn(), update: vi.fn(), }, }; vi.mock("../../server/utils/prisma", () => ({ usePrisma: () => mockPrisma, })); const mockPool = { query: vi.fn(), }; vi.mock("pg", () => ({ default: { Pool: vi.fn(() => mockPool) }, Pool: vi.fn(() => mockPool), })); vi.mock("../../server/utils/runtime-config", () => ({ useRuntimeConfig: () => ({ mdmDatabaseUrl: "postgres://fake" }), })); describe("getLinkedUserDevices", () => { beforeEach(() => { vi.clearAllMocks(); }); it("returns only iOS devices with a non-null mdmId", async () => { mockPrisma.userDevice.findMany.mockResolvedValue([ { id: "d1", userId: "u1", deviceId: "cap1", platform: "ios", mdmId: "udid-1", mdmEnrolled: true, mdmSupervised: true, mdmLastSeenAt: null }, ]); const result = await getLinkedUserDevices(); expect(result).toHaveLength(1); expect(result[0].mdmId).toBe("udid-1"); expect(mockPrisma.userDevice.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { platform: "ios", mdmId: { not: null } }, }), ); }); }); describe("getMdmEnrollmentStatusesByUdids", () => { beforeEach(() => { vi.clearAllMocks(); }); it("returns an empty map for empty input", async () => { const result = await getMdmEnrollmentStatusesByUdids([]); expect(result.size).toBe(0); expect(mockPool.query).not.toHaveBeenCalled(); }); it("maps NanoMDM rows to status objects", async () => { const lastSeen = new Date("2026-06-15T11:25:00Z"); mockPool.query.mockResolvedValue({ rows: [ { udid: "udid-1", enrolled: true, supervised: true, last_seen_at: lastSeen }, { udid: "udid-2", enrolled: false, supervised: true, last_seen_at: null }, ], }); const result = await getMdmEnrollmentStatusesByUdids(["udid-1", "udid-2"]); expect(result.get("udid-1")).toEqual({ enrolled: true, supervised: true, lastSeenAt: lastSeen }); expect(result.get("udid-2")).toEqual({ enrolled: false, supervised: true, lastSeenAt: null }); }); }); describe("updateUserDeviceMdmHealth", () => { beforeEach(() => { vi.clearAllMocks(); }); it("updates the mirrored status columns", async () => { mockPrisma.userDevice.update.mockResolvedValue({ id: "d1" }); await updateUserDeviceMdmHealth("d1", { enrolled: true, supervised: false, lastSeenAt: new Date("2026-06-15T11:25:00Z"), }); expect(mockPrisma.userDevice.update).toHaveBeenCalledWith({ where: { id: "d1" }, data: { mdmEnrolled: true, mdmSupervised: false, mdmLastSeenAt: new Date("2026-06-15T11:25:00Z"), }, }); }); }); ``` - [ ] **Step 2: Run tests** Run: `cd backend && pnpm test backend/tests/devices/mdm-health.test.ts` Expected: All tests pass. - [ ] **Step 3: Commit** ```bash git add backend/tests/devices/mdm-health.test.ts git commit -m "test(mdm): add MDM healthcheck DB-layer tests" ``` --- ### Task 6: Apply migration and verify - [ ] **Step 1: Apply migration to the target database** Run: `cd backend && pnpm prisma migrate deploy` Expected: Migration `20250618_add_mdm_health_columns` applies successfully. - [ ] **Step 2: Regenerate Prisma Client** Run: `cd backend && pnpm prisma generate` Expected: Client generated. - [ ] **Step 3: Run full test suite (at least device tests)** Run: `cd backend && pnpm test backend/tests/devices/` Expected: All tests pass. - [ ] **Step 4: Commit** ```bash git add backend/prisma/schema.prisma backend/prisma/migrations backend/server/db/mdm.ts backend/server/plugins/mdm-health-cron.ts backend/tests/devices/mdm-health.test.ts git commit -m "feat(mdm): implement MDM healthcheck cron with mirrored status columns" ``` --- ## Self-Review **Spec coverage:** - Healthcheck runs every 5 minutes → Task 4. - Supervised devices checked → Task 3 query filters via `unlock_token IS NOT NULL`. - Enrollment status changes persisted → Task 3 `updateUserDeviceMdmHealth`. - No new table → only `UserDevice` columns added → Tasks 1-2. **Placeholder scan:** - No TBD/TODO/fill-in-details. - All code blocks contain concrete implementation. **Type consistency:** - `MdmEnrollmentStatus` used consistently across Tasks 3, 4, 5. - Column names `mdmEnrolled`, `mdmSupervised`, `mdmLastSeenAt` match in schema, migration, DB layer, tests.