chahinebrini 5b1f89e749 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>
2026-05-15 21:16:05 +02:00

182 lines
5.3 KiB
TypeScript

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<DeviceRecord[]> {
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<DeviceRecord | null> {
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<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.
* 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<void> {
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<void> {
const db = usePrisma();
await db.userDevice.deleteMany({ where: { id, userId } });
}