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:
parent
0452007daf
commit
5b1f89e749
@ -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;
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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<DeviceRecord[]> {
|
||||
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<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: {
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
22
backend/server/utils/testUser.ts
Normal file
22
backend/server/utils/testUser.ts
Normal 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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user