chahinebrini 187a2d8c19 feat(magic): Hub Header mit Avatar+Nickname + iPhone/iPad via UserDevice-Locks + MacBook-Dedupe
- Neuer Endpoint /api/magic/me liefert nickname/avatar/plan fuer
  Hub-Header. Mac-App ruft fetchMe() beim Hub-Load.
- DeviceHubView Header zeigt jetzt Avatar (AsyncImage mit Fallback
  auf Initial-Letter), Nickname + Plan-Badge statt nur 'ReBreak Magic'.
- /api/magic/devices erweitert: listet zusaetzlich UserDevice-Rows mit
  boundToPlan != null (das sind iPhone/iPad aus dem Native-App-Login-
  Flow, Legend-Device-Lock). source='locked'.
- Dedupe: ProtectedDevice wird unterdrueckt wenn bereits ein UserDevice
  mit aehnlichem Namen + gleicher Plattform existiert (fixt doppelten
  MacBook im Hub).
- Helper prettyPlatform() + Normalisierung (platform-key 'mac'/'ios'/
  'android'/'win') fuer robusten Vergleich.
2026-06-03 11:41:06 +02:00

146 lines
4.6 KiB
TypeScript

import { listMagicDevices } from "../../db/devices";
import { listProtectedDevices } from "../../db/protectedDevices";
import { usePrisma } from "../../utils/prisma";
import { requireUser } from "../../utils/auth";
/**
* GET /api/magic/devices
*
* Vereinigt drei Quellen f\u00fcr "registriertes Ger\u00e4t":
* - "magic" \u2192 UserDevice mit magicEnrolledAt (Magic-Mac-App)
* - "locked" \u2192 UserDevice mit boundToPlan (Native-App Device-Lock, z.B. iPhone/iPad)
* - "protected" \u2192 ProtectedDevice (alter Native-App DNS-Schutz-Flow)
*
* Dedupe: ProtectedDevice wird unterdr\u00fcckt wenn bereits ein UserDevice
* mit \u00e4hnlichem Namen + gleicher Plattform existiert (verhindert MacBook-Doppel).
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const db = usePrisma();
const [magic, lockedDevices, protectedDevices] = await Promise.all([
listMagicDevices(user.id),
db.userDevice.findMany({
where: {
userId: user.id,
// Alle bound-Devices (Pro/Legend-Lock). Magic-only rows kommen
// \u00fcber `magic` rein \u2014 wir wollen hier die nicht-magic Lock-Bindings.
boundToPlan: { not: null },
magicEnrolledAt: null,
},
orderBy: [{ lastSeenAt: "desc" }, { createdAt: "desc" }],
select: {
id: true,
deviceId: true,
platform: true,
model: true,
name: true,
osVersion: true,
lastSeenAt: true,
releaseRequestedAt: true,
},
}),
listProtectedDevices(user.id),
]);
const magicItems = magic.map((d) => {
let releaseAvailableAt: string | null = null;
if (d.releaseRequestedAt) {
releaseAvailableAt = new Date(
d.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000,
).toISOString();
}
return {
source: "magic" as const,
deviceId: d.deviceId,
hostname: d.hostname ?? "Unbenanntes Ger\u00e4t",
model: d.model,
osVersion: d.osVersion,
magicEnrolledAt: d.magicEnrolledAt.toISOString(),
releaseRequestedAt: d.releaseRequestedAt?.toISOString() ?? null,
releaseAvailableAt,
};
});
const lockedItems = lockedDevices.map((d) => {
let releaseAvailableAt: string | null = null;
if (d.releaseRequestedAt) {
releaseAvailableAt = new Date(
d.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000,
).toISOString();
}
return {
source: "locked" as const,
deviceId: d.deviceId,
hostname: d.name ?? d.model ?? prettyPlatform(d.platform),
model: d.model,
osVersion: d.osVersion,
magicEnrolledAt: d.lastSeenAt.toISOString(),
releaseRequestedAt: d.releaseRequestedAt?.toISOString() ?? null,
releaseAvailableAt,
};
});
// Dedupe Helper \u2014 normalisiere platform + name f\u00fcr Vergleich
const norm = (s: string | null | undefined) =>
(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
const platformKey = (p: string | null | undefined) => {
const n = norm(p);
if (n.startsWith("mac") || n === "darwin") return "mac";
if (n.startsWith("ios") || n.startsWith("ipad") || n.startsWith("iphone"))
return "ios";
if (n.startsWith("android")) return "android";
if (n.startsWith("windows") || n === "win") return "win";
return n;
};
const alreadyListed = [...magicItems, ...lockedItems].map((d) => ({
pk: platformKey(d.model ?? d.hostname),
nameNorm: norm(d.hostname),
}));
const protectedItems = protectedDevices
.filter((pd) => {
const pk = platformKey(pd.platform);
const labelNorm = norm(pd.label);
const dup = alreadyListed.some((u) => {
if (u.pk !== pk) return false;
if (!u.nameNorm || !labelNorm) return u.pk === pk;
return (
u.nameNorm.includes(labelNorm) || labelNorm.includes(u.nameNorm)
);
});
return !dup;
})
.map((d) => ({
source: "protected" as const,
deviceId: d.id,
hostname: d.label,
model: d.platform,
osVersion: null as string | null,
magicEnrolledAt: (d.installedAt ?? d.createdAt).toISOString(),
releaseRequestedAt: null as string | null,
releaseAvailableAt: null as string | null,
}));
return {
success: true,
data: [...magicItems, ...lockedItems, ...protectedItems],
};
});
function prettyPlatform(p: string): string {
switch (p.toLowerCase()) {
case "ios":
return "iPhone / iPad";
case "android":
return "Android-Ger\u00e4t";
case "mac":
case "macos":
case "darwin":
return "Mac";
default:
return p;
}
}