387 lines
11 KiB
TypeScript
387 lines
11 KiB
TypeScript
// app/api/devices/[id]/route.ts
|
||
import { NextResponse } from 'next/server';
|
||
import { prisma } from '@/lib/prisma';
|
||
import type { Prisma } from '@prisma/client';
|
||
import { getCurrentUserId } from '@/lib/auth';
|
||
import type { Server as IOServer } from 'socket.io';
|
||
|
||
type RouteParams = { id: string };
|
||
|
||
// in Next 15+ ist params ein Promise
|
||
type RouteContext = { params: Promise<RouteParams> };
|
||
|
||
export async function GET(
|
||
_req: Request,
|
||
ctx: RouteContext,
|
||
) {
|
||
// params-Promise auflösen
|
||
const { id } = await ctx.params;
|
||
|
||
if (!id) {
|
||
return NextResponse.json({ error: 'MISSING_ID' }, { status: 400 });
|
||
}
|
||
|
||
try {
|
||
const device = await prisma.device.findUnique({
|
||
where: { inventoryNumber: id },
|
||
include: {
|
||
group: true,
|
||
location: true,
|
||
tags: true, // 🔹 NEU
|
||
},
|
||
});
|
||
|
||
if (!device) {
|
||
return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 });
|
||
}
|
||
|
||
return NextResponse.json({
|
||
inventoryNumber: device.inventoryNumber,
|
||
name: device.name,
|
||
manufacturer: device.manufacturer,
|
||
model: device.model,
|
||
serialNumber: device.serialNumber,
|
||
productNumber: device.productNumber,
|
||
comment: device.comment,
|
||
ipv4Address: device.ipv4Address,
|
||
ipv6Address: device.ipv6Address,
|
||
macAddress: device.macAddress,
|
||
username: device.username,
|
||
// passwordHash bewusst weggelassen
|
||
group: device.group?.name ?? null,
|
||
location: device.location?.name ?? null,
|
||
tags: device.tags.map((t) => t.name), // 🔹
|
||
createdAt: device.createdAt.toISOString(),
|
||
updatedAt: device.updatedAt.toISOString(),
|
||
});
|
||
|
||
} catch (err) {
|
||
console.error('[GET /api/devices/[id]]', err);
|
||
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
|
||
export async function PATCH(
|
||
req: Request,
|
||
ctx: RouteContext,
|
||
) {
|
||
const { id } = await ctx.params;
|
||
const body = await req.json();
|
||
|
||
if (!id) {
|
||
return NextResponse.json({ error: 'MISSING_ID' }, { status: 400 });
|
||
}
|
||
|
||
try {
|
||
// ⬇️ hier jetzt die Request-Header durchreichen
|
||
const userId = await getCurrentUserId();
|
||
|
||
// aktuelles Gerät inkl. Relations laden (für "before"-Snapshot)
|
||
const existing = await prisma.device.findUnique({
|
||
where: { inventoryNumber: id },
|
||
include: {
|
||
group: true,
|
||
location: true,
|
||
tags: true, // 🔹 NEU
|
||
},
|
||
});
|
||
|
||
if (!existing) {
|
||
return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 });
|
||
}
|
||
|
||
// Basis-Update-Daten
|
||
const data: Prisma.DeviceUpdateInput = {
|
||
name: body.name,
|
||
manufacturer: body.manufacturer,
|
||
model: body.model,
|
||
serialNumber: body.serialNumber,
|
||
productNumber: body.productNumber,
|
||
comment: body.comment,
|
||
ipv4Address: body.ipv4Address,
|
||
ipv6Address: body.ipv6Address,
|
||
macAddress: body.macAddress,
|
||
username: body.username,
|
||
passwordHash: body.passwordHash,
|
||
};
|
||
|
||
// Tags aus dem Body bereinigen
|
||
const incomingTags: string[] = Array.isArray(body.tags)
|
||
? body.tags.map((t: unknown) => String(t).trim()).filter(Boolean)
|
||
: [];
|
||
|
||
// existierende Tag-Namen
|
||
const existingTagNames = existing.tags.map((t) => t.name);
|
||
|
||
// welche sollen entfernt werden?
|
||
const tagsToRemove = existingTagNames.filter(
|
||
(name) => !incomingTags.includes(name),
|
||
);
|
||
|
||
// welche sind neu hinzuzufügen?
|
||
const tagsToAdd = incomingTags.filter(
|
||
(name) => !existingTagNames.includes(name),
|
||
);
|
||
|
||
if (!data.tags) {
|
||
data.tags = {};
|
||
}
|
||
|
||
// Tags, die nicht mehr vorkommen → disconnect über name (weil @unique)
|
||
if (tagsToRemove.length > 0) {
|
||
(data.tags as any).disconnect = tagsToRemove.map((name) => ({ name }));
|
||
}
|
||
|
||
// neue Tags → connectOrCreate (Tag wird bei Bedarf angelegt)
|
||
if (tagsToAdd.length > 0) {
|
||
(data.tags as any).connectOrCreate = tagsToAdd.map((name) => ({
|
||
where: { name },
|
||
create: { name },
|
||
}));
|
||
}
|
||
|
||
// updatedBy setzen, wenn User da
|
||
if (userId) {
|
||
data.updatedBy = {
|
||
connect: { id: userId },
|
||
};
|
||
}
|
||
|
||
// Gruppe (per Name) – DeviceGroup.name ist @unique
|
||
if (body.group != null && body.group !== '') {
|
||
data.group = {
|
||
connectOrCreate: {
|
||
where: { name: body.group },
|
||
create: { name: body.group },
|
||
},
|
||
};
|
||
} else {
|
||
data.group = { disconnect: true };
|
||
}
|
||
|
||
// Standort (per Name) – Location.name ist @unique
|
||
if (body.location != null && body.location !== '') {
|
||
data.location = {
|
||
connectOrCreate: {
|
||
where: { name: body.location },
|
||
create: { name: body.location },
|
||
},
|
||
};
|
||
} else {
|
||
data.location = { disconnect: true };
|
||
}
|
||
|
||
// Tags (Many-to-Many via Tag.name @unique)
|
||
if (Array.isArray(body.tags)) {
|
||
const tagNames = (body.tags as string[])
|
||
.map((t) => String(t).trim())
|
||
.filter(Boolean);
|
||
|
||
const beforeNames = existing.tags.map((t) => t.name.toLowerCase());
|
||
|
||
const normalized = tagNames.map((n) => n.toLowerCase());
|
||
const toConnect = tagNames.filter(
|
||
(n) => !beforeNames.includes(n.toLowerCase()),
|
||
);
|
||
const toDisconnect = existing.tags
|
||
.map((t) => t.name)
|
||
.filter((n) => !normalized.includes(n.toLowerCase()));
|
||
|
||
data.tags = {
|
||
// neue / fehlende Tags verknüpfen (und ggf. anlegen)
|
||
connectOrCreate: toConnect.map((name) => ({
|
||
where: { name },
|
||
create: { name },
|
||
})),
|
||
// nicht mehr vorhandene Tags trennen
|
||
disconnect: toDisconnect.map((name) => ({ name })),
|
||
};
|
||
}
|
||
|
||
// Update durchführen (für "after"-Snapshot)
|
||
const updated = await prisma.device.update({
|
||
where: { inventoryNumber: id },
|
||
data,
|
||
include: {
|
||
group: true,
|
||
location: true,
|
||
tags: true,
|
||
},
|
||
});
|
||
|
||
// Felder, die wir tracken wollen
|
||
const trackedFields = [
|
||
'name',
|
||
'manufacturer',
|
||
'model',
|
||
'serialNumber',
|
||
'productNumber',
|
||
'comment',
|
||
'ipv4Address',
|
||
'ipv6Address',
|
||
'macAddress',
|
||
'username',
|
||
'passwordHash',
|
||
] as const;
|
||
|
||
type TrackedField = (typeof trackedFields)[number];
|
||
|
||
// explizit JSON-kompatible Types (string | null)
|
||
const changes: {
|
||
field: TrackedField | 'group' | 'location' | 'tags';
|
||
before: string | null;
|
||
after: string | null;
|
||
}[] = [];
|
||
|
||
// einfache Feld-Diffs
|
||
for (const field of trackedFields) {
|
||
const before = (existing as any)[field] as string | null;
|
||
const after = (updated as any)[field] as string | null;
|
||
if (before !== after) {
|
||
changes.push({ field, before, after });
|
||
}
|
||
}
|
||
|
||
// group / location per Name vergleichen
|
||
const beforeGroup = (existing.group?.name ?? null) as string | null;
|
||
const afterGroup = (updated.group?.name ?? null) as string | null;
|
||
if (beforeGroup !== afterGroup) {
|
||
changes.push({
|
||
field: 'group',
|
||
before: beforeGroup,
|
||
after: afterGroup,
|
||
});
|
||
}
|
||
|
||
const beforeLocation = (existing.location?.name ?? null) as string | null;
|
||
const afterLocation = (updated.location?.name ?? null) as string | null;
|
||
if (beforeLocation !== afterLocation) {
|
||
changes.push({
|
||
field: 'location',
|
||
before: beforeLocation,
|
||
after: afterLocation,
|
||
});
|
||
}
|
||
|
||
// Tags vergleichen (als kommagetrennte Liste)
|
||
const beforeTagsList = existing.tags.map((t) => t.name).sort();
|
||
const afterTagsList = updated.tags.map((t) => t.name).sort();
|
||
|
||
const beforeTags =
|
||
beforeTagsList.length > 0 ? beforeTagsList.join(', ') : null;
|
||
const afterTags =
|
||
afterTagsList.length > 0 ? afterTagsList.join(', ') : null;
|
||
|
||
if (beforeTags !== afterTags) {
|
||
changes.push({
|
||
field: 'tags',
|
||
before: beforeTags,
|
||
after: afterTags,
|
||
});
|
||
}
|
||
|
||
|
||
// Falls sich *gar nichts* geändert hat, kein History-Eintrag
|
||
if (changes.length > 0) {
|
||
const snapshot: Prisma.JsonObject = {
|
||
before: {
|
||
inventoryNumber: existing.inventoryNumber,
|
||
name: existing.name,
|
||
manufacturer: existing.manufacturer,
|
||
model: existing.model,
|
||
serialNumber: existing.serialNumber,
|
||
productNumber: existing.productNumber,
|
||
comment: existing.comment,
|
||
ipv4Address: existing.ipv4Address,
|
||
ipv6Address: existing.ipv6Address,
|
||
macAddress: existing.macAddress,
|
||
username: existing.username,
|
||
passwordHash: existing.passwordHash,
|
||
group: existing.group?.name ?? null,
|
||
location: existing.location?.name ?? null,
|
||
tags: existing.tags.map((t) => t.name),
|
||
createdAt: existing.createdAt.toISOString(),
|
||
updatedAt: existing.updatedAt.toISOString(),
|
||
},
|
||
after: {
|
||
inventoryNumber: updated.inventoryNumber,
|
||
name: updated.name,
|
||
manufacturer: updated.manufacturer,
|
||
model: updated.model,
|
||
serialNumber: updated.serialNumber,
|
||
productNumber: updated.productNumber,
|
||
comment: updated.comment,
|
||
ipv4Address: updated.ipv4Address,
|
||
ipv6Address: updated.ipv6Address,
|
||
macAddress: updated.macAddress,
|
||
username: updated.username,
|
||
passwordHash: updated.passwordHash,
|
||
group: updated.group?.name ?? null,
|
||
location: updated.location?.name ?? null,
|
||
tags: updated.tags.map((t) => t.name),
|
||
createdAt: updated.createdAt.toISOString(),
|
||
updatedAt: updated.updatedAt.toISOString(),
|
||
},
|
||
changes: changes.map((c) => ({
|
||
field: c.field,
|
||
before: c.before,
|
||
after: c.after,
|
||
})),
|
||
};
|
||
|
||
await prisma.deviceHistory.create({
|
||
data: {
|
||
deviceId: updated.inventoryNumber,
|
||
changeType: 'UPDATED',
|
||
snapshot,
|
||
changedById: userId ?? null,
|
||
},
|
||
});
|
||
}
|
||
|
||
// 🔊 Socket.IO-Broadcast für echte Live-Updates
|
||
const io = (global as any).devicesIo as IOServer | undefined;
|
||
if (io) {
|
||
io.emit('device:updated', {
|
||
inventoryNumber: updated.inventoryNumber,
|
||
name: updated.name,
|
||
manufacturer: updated.manufacturer,
|
||
model: updated.model,
|
||
serialNumber: updated.serialNumber,
|
||
productNumber: updated.productNumber,
|
||
comment: updated.comment,
|
||
ipv4Address: updated.ipv4Address,
|
||
ipv6Address: updated.ipv6Address,
|
||
macAddress: updated.macAddress,
|
||
username: updated.username,
|
||
group: updated.group?.name ?? null,
|
||
location: updated.location?.name ?? null,
|
||
updatedAt: updated.updatedAt.toISOString(),
|
||
});
|
||
}
|
||
|
||
// Antwort an den Client (flattened)
|
||
return NextResponse.json({
|
||
inventoryNumber: updated.inventoryNumber,
|
||
name: updated.name,
|
||
manufacturer: updated.manufacturer,
|
||
model: updated.model,
|
||
serialNumber: updated.serialNumber,
|
||
productNumber: updated.productNumber,
|
||
comment: updated.comment,
|
||
ipv4Address: updated.ipv4Address,
|
||
ipv6Address: updated.ipv6Address,
|
||
macAddress: updated.macAddress,
|
||
username: updated.username,
|
||
group: updated.group?.name ?? null,
|
||
location: updated.location?.name ?? null,
|
||
createdAt: updated.createdAt.toISOString(),
|
||
updatedAt: updated.updatedAt.toISOString(),
|
||
});
|
||
} catch (err) {
|
||
console.error('[PATCH /api/devices/[id]]', err);
|
||
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
|
||
}
|
||
}
|