feat(backend): device-info schema + merge heuristic + test-user detection

- 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 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-15 21:16:05 +02:00
parent 0452007daf
commit 5b1f89e749
5 changed files with 110 additions and 41 deletions

View File

@ -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;

View File

@ -846,6 +846,7 @@ model UserDevice {
platform String // "ios" | "android" | "web" platform String // "ios" | "android" | "web"
model String? // z.B. "iPhone15,2" model String? // z.B. "iPhone15,2"
name String? // z.B. "Chahines iPhone" name String? // z.B. "Chahines iPhone"
osVersion String? @map("os_version") // z.B. "18.4.1"
lastSeenAt DateTime @default(now()) @map("last_seen_at") lastSeenAt DateTime @default(now()) @map("last_seen_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")

View File

@ -15,11 +15,12 @@ export default defineEventHandler(async (event) => {
// Bootstrap: kein Device-Check sonst wäre erstes Register unmöglich (chicken-egg) // Bootstrap: kein Device-Check sonst wäre erstes Register unmöglich (chicken-egg)
const user = await requireUser(event, { skipDeviceCheck: true }); const user = await requireUser(event, { skipDeviceCheck: true });
const body = await readBody(event); const body = await readBody(event);
const { deviceId, platform, model, name } = body as { const { deviceId, platform, model, name, osVersion } = body as {
deviceId?: string; deviceId?: string;
platform?: string; platform?: string;
model?: string; model?: string;
name?: string; name?: string;
osVersion?: string;
}; };
if (!deviceId || !platform) { if (!deviceId || !platform) {
@ -36,18 +37,23 @@ export default defineEventHandler(async (event) => {
const limits = getPlanLimits(profile?.plan ?? "free"); const limits = getPlanLimits(profile?.plan ?? "free");
try { try {
const { device, created } = await registerDevice({ const { device, created, merged } = await registerDevice({
userId: user.id, userId: user.id,
deviceId, deviceId,
platform, platform,
model: model ?? null, model: model ?? null,
name: name ?? null, name: name ?? null,
osVersion: osVersion ?? null,
maxDevices: limits.maxAppDevices, maxDevices: limits.maxAppDevices,
}); });
return { device, created, max: limits.maxAppDevices }; return { device, created, merged: merged ?? false, max: limits.maxAppDevices };
} catch (err: any) { } catch (err: any) {
if (err.code === "DEVICE_LIMIT_REACHED") { 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({ throw createError({
statusCode: 403, statusCode: 403,
statusMessage: "device_limit_reached", statusMessage: "device_limit_reached",

View File

@ -11,25 +11,29 @@ export interface DeviceRecord {
platform: string; platform: string;
model: string | null; model: string | null;
name: string | null; name: string | null;
osVersion: string | null;
lastSeenAt: Date; lastSeenAt: Date;
createdAt: 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. */ /** Liste aller Devices eines Users, aktuellstes zuerst. */
export async function listUserDevices(userId: string): Promise<DeviceRecord[]> { export async function listUserDevices(userId: string): Promise<DeviceRecord[]> {
const db = usePrisma(); const db = usePrisma();
return db.userDevice.findMany({ return db.userDevice.findMany({
where: { userId }, where: { userId },
orderBy: { lastSeenAt: "desc" }, orderBy: { lastSeenAt: "desc" },
select: { select: DEVICE_SELECT,
id: true,
deviceId: true,
platform: true,
model: true,
name: true,
lastSeenAt: true,
createdAt: true,
},
}); });
} }
@ -41,20 +45,47 @@ export async function findUserDevice(
const db = usePrisma(); const db = usePrisma();
return db.userDevice.findUnique({ return db.userDevice.findUnique({
where: { userId_deviceId: { userId, deviceId } }, where: { userId_deviceId: { userId, deviceId } },
select: { select: DEVICE_SELECT,
id: true,
deviceId: true,
platform: true,
model: true,
name: true,
lastSeenAt: true,
createdAt: true,
},
}); });
} }
/**
* 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<DeviceRecord | null> {
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. * 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. * Wenn nicht existiert UND Limit erreicht: throw mit Liste der existierenden Devices.
*/ */
export async function registerDevice(opts: { export async function registerDevice(opts: {
@ -63,17 +94,19 @@ export async function registerDevice(opts: {
platform: string; platform: string;
model?: string | null; model?: string | null;
name?: string | null; name?: string | null;
osVersion?: string | null;
maxDevices: number; maxDevices: number;
}): Promise<{ }): Promise<{
device: DeviceRecord; device: DeviceRecord;
created: boolean; created: boolean;
merged?: boolean;
}> { }> {
const db = usePrisma(); const db = usePrisma();
// Idempotent: existiert das Device schon? // Idempotent: existiert das Device schon?
const existing = await findUserDevice(opts.userId, opts.deviceId); const existing = await findUserDevice(opts.userId, opts.deviceId);
if (existing) { 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). // kann sich geändert haben (App-Update, OS-Upgrade, iPad-Detection-Fix).
const updated = await db.userDevice.update({ const updated = await db.userDevice.update({
where: { id: existing.id }, where: { id: existing.id },
@ -81,20 +114,29 @@ export async function registerDevice(opts: {
lastSeenAt: new Date(), lastSeenAt: new Date(),
...(opts.model !== undefined && { model: opts.model }), ...(opts.model !== undefined && { model: opts.model }),
...(opts.name !== undefined && { name: opts.name }), ...(opts.name !== undefined && { name: opts.name }),
...(opts.osVersion !== undefined && { osVersion: opts.osVersion }),
}, },
select: { select: DEVICE_SELECT,
id: true,
deviceId: true,
platform: true,
model: true,
name: true,
lastSeenAt: true,
createdAt: true,
},
}); });
return { device: updated, created: false }; 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 // Neues Device — Limit prüfen
const count = await db.userDevice.count({ where: { userId: opts.userId } }); const count = await db.userDevice.count({ where: { userId: opts.userId } });
if (count >= opts.maxDevices) { if (count >= opts.maxDevices) {
@ -112,16 +154,9 @@ export async function registerDevice(opts: {
platform: opts.platform, platform: opts.platform,
model: opts.model ?? null, model: opts.model ?? null,
name: opts.name ?? null, name: opts.name ?? null,
osVersion: opts.osVersion ?? null,
}, },
select: { select: DEVICE_SELECT,
id: true,
deviceId: true,
platform: true,
model: true,
name: true,
lastSeenAt: true,
createdAt: true,
},
}); });
return { device: created, created: true }; return { device: created, created: true };
} }

View File

@ -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<string> = [
'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);
}