chahinebrini 60f608d891 fix(backend): backfill device name/model/osVersion on touch + auto-register
Bug: existing devices registered before Frontend started sending x-device-name/
x-device-model/x-device-os headers stayed with NULL fields forever — DeviceLimit
sheet shows only platform label ("iPhone" without iOS version, no name).

Fix:
- touchDevice() now accepts optional { name, model, osVersion } and updates
  these fields when headers are provided (existing-row backfill).
- requireUser auth middleware reads URL-encoded x-device-* headers + passes
  them to both touchDevice() (existing) and registerDevice() (auto-register).

After deploy: next authenticated request from updated client backfills the
device record automatically (throttled per TOUCH_THROTTLE_MS = 1×/min).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:27:58 +02:00

194 lines
5.8 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.
* Optional: backfillt name/model/osVersion wenn Headers da sind (für Devices
* die unter altem Build registriert wurden ohne diese Info). */
export async function touchDevice(
userId: string,
deviceId: string,
info?: { name?: string | null; model?: string | null; osVersion?: string | null },
): Promise<void> {
const db = usePrisma();
const data: { lastSeenAt: Date; name?: string; model?: string; osVersion?: string } = {
lastSeenAt: new Date(),
};
if (info?.name) data.name = info.name;
if (info?.model) data.model = info.model;
if (info?.osVersion) data.osVersion = info.osVersion;
await db.userDevice
.updateMany({
where: { userId, deviceId },
data,
})
.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 } });
}