From 5b1f89e749ee628cebd14d683fe97883cec35a4b Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Fri, 15 May 2026 21:16:05 +0200 Subject: [PATCH] feat(backend): device-info schema + merge heuristic + test-user detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Schema: lyraVoiceId stays, new os_version column on user_devices (Migration 20260515) - registerDevice() merge-heuristic: if existing record matches userId + same name + same model + lastSeen < 30 days, update existing instead of inserting new. Fixes iOS IDFV-reset creating phantom devices on Recovery-Restore. - register.post.ts: accepts osVersion in body, maps isCurrent in error-path payload - New util testUser.ts: isTestUser(email) — explicit allowlist for charioanouar@gmail.com plus existing @rebreak.internal suffix Co-Authored-By: Claude Opus 4.7 --- .../migration.sql | 5 + backend/prisma/schema.prisma | 1 + backend/server/api/devices/register.post.ts | 14 ++- backend/server/db/devices.ts | 109 ++++++++++++------ backend/server/utils/testUser.ts | 22 ++++ 5 files changed, 110 insertions(+), 41 deletions(-) create mode 100644 backend/prisma/migrations/20260515_add_user_device_os_version/migration.sql create mode 100644 backend/server/utils/testUser.ts diff --git a/backend/prisma/migrations/20260515_add_user_device_os_version/migration.sql b/backend/prisma/migrations/20260515_add_user_device_os_version/migration.sql new file mode 100644 index 0000000..26b7aaa --- /dev/null +++ b/backend/prisma/migrations/20260515_add_user_device_os_version/migration.sql @@ -0,0 +1,5 @@ +-- Add os_version column to user_devices. +-- Enables merge-heuristic in registerDevice() to coalesce IDFV-reset phantom devices. + +ALTER TABLE "rebreak"."user_devices" + ADD COLUMN IF NOT EXISTS "os_version" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 841a922..9f9a49b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -846,6 +846,7 @@ model UserDevice { platform String // "ios" | "android" | "web" model String? // z.B. "iPhone15,2" name String? // z.B. "Chahines iPhone" + osVersion String? @map("os_version") // z.B. "18.4.1" lastSeenAt DateTime @default(now()) @map("last_seen_at") createdAt DateTime @default(now()) @map("created_at") diff --git a/backend/server/api/devices/register.post.ts b/backend/server/api/devices/register.post.ts index 1af093f..370c9d3 100644 --- a/backend/server/api/devices/register.post.ts +++ b/backend/server/api/devices/register.post.ts @@ -15,11 +15,12 @@ export default defineEventHandler(async (event) => { // Bootstrap: kein Device-Check sonst wäre erstes Register unmöglich (chicken-egg) const user = await requireUser(event, { skipDeviceCheck: true }); const body = await readBody(event); - const { deviceId, platform, model, name } = body as { + const { deviceId, platform, model, name, osVersion } = body as { deviceId?: string; platform?: string; model?: string; name?: string; + osVersion?: string; }; if (!deviceId || !platform) { @@ -36,18 +37,23 @@ export default defineEventHandler(async (event) => { const limits = getPlanLimits(profile?.plan ?? "free"); try { - const { device, created } = await registerDevice({ + const { device, created, merged } = await registerDevice({ userId: user.id, deviceId, platform, model: model ?? null, name: name ?? null, + osVersion: osVersion ?? null, maxDevices: limits.maxAppDevices, }); - return { device, created, max: limits.maxAppDevices }; + return { device, created, merged: merged ?? false, max: limits.maxAppDevices }; } catch (err: any) { if (err.code === "DEVICE_LIMIT_REACHED") { - const devices = await listUserDevices(user.id); + const allDevices = await listUserDevices(user.id); + const devices = allDevices.map((d) => ({ + ...d, + isCurrent: d.deviceId === deviceId, + })); throw createError({ statusCode: 403, statusMessage: "device_limit_reached", diff --git a/backend/server/db/devices.ts b/backend/server/db/devices.ts index 60474df..3678c76 100644 --- a/backend/server/db/devices.ts +++ b/backend/server/db/devices.ts @@ -11,25 +11,29 @@ export interface DeviceRecord { 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: { - id: true, - deviceId: true, - platform: true, - model: true, - name: true, - lastSeenAt: true, - createdAt: true, - }, + select: DEVICE_SELECT, }); } @@ -41,20 +45,47 @@ export async function findUserDevice( const db = usePrisma(); return db.userDevice.findUnique({ where: { userId_deviceId: { userId, deviceId } }, - select: { - id: true, - deviceId: true, - platform: true, - model: true, - name: true, - lastSeenAt: true, - createdAt: true, - }, + 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: { @@ -63,17 +94,19 @@ export async function registerDevice(opts: { 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 beim Re-Register aktualisieren — User-Agent oder OS-Version + // 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 }, @@ -81,20 +114,29 @@ export async function registerDevice(opts: { lastSeenAt: new Date(), ...(opts.model !== undefined && { model: opts.model }), ...(opts.name !== undefined && { name: opts.name }), + ...(opts.osVersion !== undefined && { osVersion: opts.osVersion }), }, - select: { - id: true, - deviceId: true, - platform: true, - model: true, - name: true, - lastSeenAt: true, - createdAt: true, - }, + 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) { @@ -112,16 +154,9 @@ export async function registerDevice(opts: { platform: opts.platform, model: opts.model ?? null, name: opts.name ?? null, + osVersion: opts.osVersion ?? null, }, - select: { - id: true, - deviceId: true, - platform: true, - model: true, - name: true, - lastSeenAt: true, - createdAt: true, - }, + select: DEVICE_SELECT, }); return { device: created, created: true }; } diff --git a/backend/server/utils/testUser.ts b/backend/server/utils/testUser.ts new file mode 100644 index 0000000..1683a64 --- /dev/null +++ b/backend/server/utils/testUser.ts @@ -0,0 +1,22 @@ +/** + * Test-User-Detection. + * + * Primäre Methode: @rebreak.internal Email-Suffix (intern angelegte Test-Accounts, + * Login via /api/auth/login mit username-only). + * + * Whitelist-Methode: explizite Emails für externe Test-Accounts die NICHT das + * @rebreak.internal-System nutzen (z.B. existierende Supabase-Auth-Accounts). + * + * Wird genutzt um Test-Accounts Zugang zu Dev-Endpoints + verkürzten Timern zu geben, + * auch wenn die Umgebung nicht als "staging" konfiguriert ist. + */ + +const TEST_USER_EMAILS: ReadonlyArray = [ + 'charioanouar@gmail.com', +]; + +export function isTestUser(email: string | null | undefined): boolean { + if (!email) return false; + const lower = email.toLowerCase(); + return lower.endsWith('@rebreak.internal') || TEST_USER_EMAILS.includes(lower); +}