import { usePrisma } from "../utils/prisma"; /** * Device-Binding pro User. Free=1, Pro=1, Legend=3 (siehe plan-features.maxDevices). * deviceId kommt vom Frontend via Capacitor Device.getId() (persistent UUID). */ export interface DeviceRecord { id: string; userId: string; deviceId: string; platform: string; model: string | null; name: string | null; osVersion: string | null; lastSeenAt: Date; createdAt: Date; // Device-Account-Lock boundToPlan: string | null; releaseRequestedAt: Date | null; lockNotifiedAt: Date | null; } /** Pläne die einen Device-Account-Lock aktivieren. Free-User binden nie. */ const LOCKING_PLANS = new Set(["pro", "legend", "standard", "premium"]); /** Ist ein Plan ein "locking" Plan (Pro/Legend inkl. Legacy-Namen)? */ export function isLockingPlan(plan: string | null | undefined): boolean { if (!plan) return false; return LOCKING_PLANS.has(plan.toLowerCase()); } /** * Prüft ob ein gegebenes deviceId bereits an einen anderen User gebunden ist * (Lock aktiv). Gibt die bound Row zurück wenn ja, null wenn frei. * * "Gebunden" = boundToPlan ist gesetzt (isLockingPlan) UND kein Release * abgelaufen (releaseRequestedAt + 24h <= NOW() = released). */ export async function findActiveDeviceLock( deviceId: string, requestingUserId: string, ): Promise { const db = usePrisma(); const row = await db.userDevice.findFirst({ where: { deviceId, // Gebunden an einen anderen User NOT: { userId: requestingUserId }, // Binding existiert (Pro/Legend-Account) boundToPlan: { not: null }, }, select: { ...DEVICE_SELECT_WITH_LOCK, }, }); if (!row) return null; // Kein Lock wenn boundToPlan kein Locking-Plan (Sicherheitsnetz, eigentlich // schon durch oben gefiltert aber explizit prüfen) if (!isLockingPlan(row.boundToPlan)) return null; // Wenn Release-Request existiert und 24h abgelaufen → Lock ist released if (row.releaseRequestedAt) { const releaseAt = new Date( row.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000, ); if (releaseAt <= new Date()) return null; } // Lock ist aktiv return row; } /** * Bindet ein Device an den User (setzt boundToPlan). * Wird nach erfolgreichem Login aufgerufen wenn user.plan ein Locking-Plan ist. * Idempotent: wenn bereits gebunden → kein Update. */ export async function bindDeviceToUser( userId: string, deviceId: string, plan: string, ): Promise { if (!isLockingPlan(plan)) return; // Free-User binden nicht const db = usePrisma(); await db.userDevice.updateMany({ where: { userId, deviceId, boundToPlan: null, // Nur setzen wenn noch nicht gebunden }, data: { boundToPlan: plan, }, }); } /** * Request-Release: Original-User setzt release_requested_at = NOW(). * Nach 24h ist der Lock automatisch gelöst (Lazy-Check in findActiveDeviceLock). * Gibt false zurück wenn Device nicht gefunden oder nicht dem User gehört. */ export async function requestDeviceRelease( userId: string, deviceId: string, // row-id (UUID), nicht deviceId-String ): Promise { const db = usePrisma(); const result = await db.userDevice.updateMany({ where: { id: deviceId, userId, // Ownership-Check boundToPlan: { not: null }, // Muss gebunden sein um freizugeben }, data: { releaseRequestedAt: new Date(), }, }); return result.count > 0; } /** * Cancel-Release: User zieht den Release-Request zurück. * Setzt release_requested_at zurück auf NULL. */ export async function cancelDeviceRelease( userId: string, deviceId: string, // row-id ): Promise { const db = usePrisma(); const result = await db.userDevice.updateMany({ where: { id: deviceId, userId, releaseRequestedAt: { not: null }, // Muss offener Request sein }, data: { releaseRequestedAt: null, }, }); return result.count > 0; } /** * Setzt lockNotifiedAt für Rate-Limiting der E-Mail-Notifications. */ export async function markDeviceLockNotified(rowId: string): Promise { const db = usePrisma(); await db.userDevice .update({ where: { id: rowId }, data: { lockNotifiedAt: new Date() }, }) .catch(() => {}); } /** * Auto-Release: Alle Devices die 30 Tage nicht gesehen wurden UND noch * boundToPlan gesetzt haben → boundToPlan zurücksetzen. * Wird vom Cron-Plugin (device-lock-cron.ts) aufgerufen. * * Schützt vor verlorenen/verkauften/defekten Geräten ohne Customer-Support. */ export async function autoReleaseInactiveDevices(): Promise { const db = usePrisma(); const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const result = await db.userDevice.updateMany({ where: { boundToPlan: { not: null }, lastSeenAt: { lt: cutoff }, // Nur wenn kein Release-Request bereits pending (würde in 24h ohnehin ablaufen) }, data: { boundToPlan: null, releaseRequestedAt: null, }, }); return result.count; } const DEVICE_SELECT = { id: true, userId: true, deviceId: true, platform: true, model: true, name: true, osVersion: true, lastSeenAt: true, createdAt: true, boundToPlan: true, releaseRequestedAt: true, lockNotifiedAt: true, } as const; // Alias — identisch mit DEVICE_SELECT, explizit für Lock-Queries const DEVICE_SELECT_WITH_LOCK = DEVICE_SELECT; /** Liste aller Devices eines Users, aktuellstes zuerst. * Deterministic sort: lastSeenAt DESC, createdAt DESC, id ASC — stellt sicher * dass iPad + iPhone die GLEICHE Reihenfolge sehen wenn lastSeenAt identisch ist. */ export async function listUserDevices(userId: string): Promise { const db = usePrisma(); return db.userDevice.findMany({ where: { userId }, orderBy: [{ lastSeenAt: "desc" }, { createdAt: "desc" }, { id: "asc" }], select: DEVICE_SELECT, }); } /** * Löscht Phantom-Devices: >14 Tage nicht gesehen + nicht an Plan gebunden. * Wird von GET /api/devices + register-Limit-Check aufgerufen damit alte * IDFV-Reset-Artifacts nicht das Device-Limit blockieren oder in der UI * herumgeistern. * * Returnt Anzahl gelöschter Rows (für Logging). */ export async function cleanupStaleDevices(userId: string): Promise { const db = usePrisma(); const cutoff = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000); const res = await db.userDevice.deleteMany({ where: { userId, lastSeenAt: { lt: cutoff }, boundToPlan: null, }, }); return res.count; } /** Gibt das Device zurück wenn registriert; sonst null. */ export async function findUserDevice( userId: string, deviceId: string, ): Promise { const db = usePrisma(); return db.userDevice.findUnique({ where: { userId_deviceId: { userId, deviceId } }, select: DEVICE_SELECT, }); } /** * Sucht nach einem "Merge-Kandidaten": existierendes Device des Users mit * identischem name + model das zuletzt innerhalb der letzten 7 Tage gesehen * wurde. Tritt auf wenn iOS IDFV sich nach Recovery-Restore ändert → neuer * deviceId, aber gleicher name ("iPhone von Chahine") + gleiches model * ("iPhone18,4"). * * 2026-06-01: Cutoff 30d → 7d (verhindert False-Merges nach längerer Inaktivität). */ async function findMergeCandidate( userId: string, name: string | null | undefined, model: string | null | undefined, ): Promise { if (!name || !model) return null; const db = usePrisma(); const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const candidate = await db.userDevice.findFirst({ where: { userId, name, model, lastSeenAt: { gte: cutoff }, }, orderBy: { lastSeenAt: "desc" }, select: DEVICE_SELECT, }); return candidate; } /** * Idempotente Registrierung. Wenn Device bereits existiert: Touch lastSeenAt. * Wenn nicht existiert UND gleicher name+model innerhalb 30d: Merge (IDFV-Reset-Heuristik). * Wenn nicht existiert UND Limit erreicht: throw mit Liste der existierenden Devices. */ export async function registerDevice(opts: { userId: string; deviceId: string; platform: string; model?: string | null; name?: string | null; osVersion?: string | null; maxDevices: number; }): Promise<{ device: DeviceRecord; created: boolean; merged?: boolean; }> { const db = usePrisma(); // Idempotent: existiert das Device schon? const existing = await findUserDevice(opts.userId, opts.deviceId); if (existing) { // model/name/osVersion beim Re-Register aktualisieren — User-Agent oder OS-Version // kann sich geändert haben (App-Update, OS-Upgrade, iPad-Detection-Fix). const updated = await db.userDevice.update({ where: { id: existing.id }, data: { lastSeenAt: new Date(), ...(opts.model !== undefined && { model: opts.model }), ...(opts.name !== undefined && { name: opts.name }), ...(opts.osVersion !== undefined && { osVersion: opts.osVersion }), }, select: DEVICE_SELECT, }); return { device: updated, created: false }; } // Merge-Heuristik: IDFV-Reset nach iOS Recovery-Restore. // Wenn name+model matchen und Device zuletzt < 30 Tage gesehen → merge statt insert. const mergeTarget = await findMergeCandidate( opts.userId, opts.name, opts.model, ); if (mergeTarget) { const merged = await db.userDevice.update({ where: { id: mergeTarget.id }, data: { deviceId: opts.deviceId, // neue IDFV übernehmen lastSeenAt: new Date(), ...(opts.osVersion !== undefined && { osVersion: opts.osVersion }), }, select: DEVICE_SELECT, }); return { device: merged, created: false, merged: true }; } // Neues Device — Limit prüfen. // 2026-06-01 Auto-Cleanup: alte Phantom-Devices (z.B. nach iOS-Reset mit // geändertem Namen, die durch Merge-Heuristik durchgerutscht sind) blockieren // sonst das Limit. await cleanupStaleDevices(opts.userId); const count = await db.userDevice.count({ where: { userId: opts.userId } }); if (count >= opts.maxDevices) { throw Object.assign(new Error("device_limit_reached"), { code: "DEVICE_LIMIT_REACHED", currentCount: count, max: opts.maxDevices, }); } const created = await db.userDevice.create({ data: { userId: opts.userId, deviceId: opts.deviceId, platform: opts.platform, model: opts.model ?? null, name: opts.name ?? null, osVersion: opts.osVersion ?? null, }, select: DEVICE_SELECT, }); return { device: created, created: true }; } /** Touch lastSeenAt — wird in der Auth-Middleware bei jedem Request aufgerufen. * Optional: backfillt name/model/osVersion wenn Headers da sind (für Devices * die unter altem Build registriert wurden ohne diese Info). */ export async function touchDevice( userId: string, deviceId: string, info?: { name?: string | null; model?: string | null; osVersion?: string | null; }, ): Promise { const db = usePrisma(); const data: { lastSeenAt: Date; name?: string; model?: string; osVersion?: string; } = { lastSeenAt: new Date(), }; if (info?.name) data.name = info.name; if (info?.model) data.model = info.model; if (info?.osVersion) data.osVersion = info.osVersion; await db.userDevice .updateMany({ where: { userId, deviceId }, data, }) .catch(() => { /* race-safe: wenn Device gerade gelöscht wurde */ }); } /** User entfernt ein eigenes Device — gibt Slot frei. */ export async function deleteUserDevice( userId: string, id: string, ): Promise { const db = usePrisma(); await db.userDevice.deleteMany({ where: { id, userId } }); } // ───────────────────────────────────────────────────────────────────────────── // RebreakMagic DNS-Device-Binding // ───────────────────────────────────────────────────────────────────────────── /** Hard-Limit für Magic-Bindings pro User (Plan-unabhängig für MVP). */ export const MAGIC_DEVICE_LIMIT = 3; export interface MagicDeviceRecord { deviceId: string; hostname: string | null; model: string | null; osVersion: string | null; magicEnrolledAt: Date; releaseRequestedAt: Date | null; } /** * Listet alle aktiven Magic-Bindings eines Users. * Aktiv = magicEnrolledAt != null AND magicRevokedAt == null. */ export async function listMagicDevices( userId: string, ): Promise { const db = usePrisma(); const devices = await db.userDevice.findMany({ where: { userId, magicEnrolledAt: { not: null }, magicRevokedAt: null, }, orderBy: { magicEnrolledAt: "desc" }, select: { deviceId: true, magicHostname: true, model: true, osVersion: true, magicEnrolledAt: true, releaseRequestedAt: true, }, }); return devices.map((d) => ({ deviceId: d.deviceId, hostname: d.magicHostname, model: d.model, osVersion: d.osVersion, magicEnrolledAt: d.magicEnrolledAt!, releaseRequestedAt: d.releaseRequestedAt, })); } /** * Zählt aktive Magic-Bindings für Limit-Check. */ export async function countActiveMagicBindings( userId: string, ): Promise { const db = usePrisma(); return db.userDevice.count({ where: { userId, magicEnrolledAt: { not: null }, magicRevokedAt: null, }, }); } /** * Findet Device anhand DNS-Token. Nur aktive Tokens (nicht revoked). */ export async function findMagicDeviceByToken( token: string, ): Promise<(DeviceRecord & { magicDnsToken: string }) | null> { const db = usePrisma(); const device = await db.userDevice.findUnique({ where: { magicDnsToken: token, }, select: { ...DEVICE_SELECT, magicDnsToken: true, magicEnrolledAt: true, magicRevokedAt: true, magicHostname: true, }, }); if (!device) return null; if (device.magicRevokedAt) return null; // Token invalidiert return { ...device, magicDnsToken: device.magicDnsToken!, }; }