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>
This commit is contained in:
chahinebrini 2026-05-15 21:27:58 +02:00
parent e1ba0ebeaf
commit 60f608d891
2 changed files with 35 additions and 5 deletions

View File

@ -161,13 +161,25 @@ export async function registerDevice(opts: {
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> {
/** 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: { lastSeenAt: new Date() },
data,
})
.catch(() => {
/* race-safe: wenn Device gerade gelöscht wurde */

View File

@ -53,11 +53,26 @@ export async function requireUser(
const deviceId = getHeader(event, 'x-device-id');
if (!deviceId) return user;
// Frontend sendet name/model/osVersion als x-device-* Headers (URL-encoded)
function readEncoded(name: string): string | null {
const raw = getHeader(event, name);
if (!raw) return null;
try { return decodeURIComponent(raw); } catch { return raw; }
}
const deviceName = readEncoded('x-device-name');
const deviceModel = readEncoded('x-device-model');
const deviceOs = readEncoded('x-device-os');
const existing = await findUserDevice(user.id, deviceId);
if (existing) {
// Touch lastSeenAt, throttled auf 1×/min — fire-and-forget
// Touch lastSeenAt + Backfill name/model/osVersion falls noch nicht gesetzt,
// throttled auf 1×/min — fire-and-forget
if (Date.now() - existing.lastSeenAt.getTime() > TOUCH_THROTTLE_MS) {
touchDevice(user.id, deviceId).catch(() => {});
touchDevice(user.id, deviceId, {
name: deviceName,
model: deviceModel,
osVersion: deviceOs,
}).catch(() => {});
}
return user;
}
@ -73,6 +88,9 @@ export async function requireUser(
userId: user.id,
deviceId,
platform,
name: deviceName,
model: deviceModel,
osVersion: deviceOs,
maxDevices: limits.maxAppDevices,
});
return user;