- 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>
182 lines
5.3 KiB
TypeScript
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 } });
|
|
}
|