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; deviceId: string; platform: string; model: string | null; name: string | null; osVersion: string | null; lastSeenAt: Date; createdAt: Date; } const DEVICE_SELECT = { id: true, deviceId: true, platform: true, model: true, name: true, osVersion: true, lastSeenAt: true, createdAt: true, } as const; /** Liste aller Devices eines Users, aktuellstes zuerst. */ export async function listUserDevices(userId: string): Promise { const db = usePrisma(); return db.userDevice.findMany({ where: { userId }, orderBy: { lastSeenAt: "desc" }, select: DEVICE_SELECT, }); } /** 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 30 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"). * * Bekannter Trade-off: zwei physisch identische iPhones mit gleichem Namen * würden gemerged. Edge-case, bewusst akzeptiert. */ 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() - 30 * 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 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. */ export async function touchDevice(userId: string, deviceId: string): Promise { const db = usePrisma(); await db.userDevice .updateMany({ where: { userId, deviceId }, data: { lastSeenAt: new Date() }, }) .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 } }); }