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"
|
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")
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
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