2025-11-26 08:02:48 +01:00

892 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// app/api/devices/[id]/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import type { Prisma } from '@/generated/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) {
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,
parentDevice: true,
accessories: true,
},
});
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,
group: device.group?.name ?? null,
location: device.location?.name ?? null,
tags: device.tags.map((t) => t.name),
loanedTo: device.loanedTo,
loanedFrom: device.loanedFrom
? device.loanedFrom.toISOString()
: null,
loanedUntil: device.loanedUntil
? device.loanedUntil.toISOString()
: null,
loanComment: device.loanComment,
parentInventoryNumber: device.parentDeviceId,
parentName: device.parentDevice?.name ?? null,
accessories: device.accessories.map((a) => ({
inventoryNumber: a.inventoryNumber,
name: a.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 POST(req: Request) {
try {
const body = await req.json();
const {
inventoryNumber,
name,
manufacturer,
model,
serialNumber,
productNumber,
comment,
group,
location,
ipv4Address,
ipv6Address,
macAddress,
username,
passwordHash,
tags,
parentInventoryNumber,
loanedTo,
loanedFrom,
loanedUntil,
loanComment,
} = body;
if (!inventoryNumber || !name) {
return NextResponse.json(
{ error: 'inventoryNumber und name sind Pflichtfelder.' },
{ status: 400 },
);
}
const existing = await prisma.device.findUnique({
where: { inventoryNumber },
});
if (existing) {
return NextResponse.json(
{ error: 'Gerät mit dieser Inventar-Nr. existiert bereits.' },
{ status: 409 },
);
}
const userId = await getCurrentUserId();
let canConnectUser = false;
if (userId) {
const user = await prisma.user.findUnique({
where: { nwkennung: userId },
select: { nwkennung: true },
});
if (user) {
canConnectUser = true;
} else {
console.warn(
`[POST /api/devices] User mit nwkennung=${userId} nicht gefunden createdBy/changedBy werden nicht gesetzt.`,
);
}
}
// Gruppe
let groupId: string | null = null;
if (group && typeof group === 'string' && group.trim() !== '') {
const grp = await prisma.deviceGroup.upsert({
where: { name: group.trim() },
update: {},
create: { name: group.trim() },
});
groupId = grp.id;
}
// Standort
let locationId: string | null = null;
if (location && typeof location === 'string' && location.trim() !== '') {
const loc = await prisma.location.upsert({
where: { name: location.trim() },
update: {},
create: { name: location.trim() },
});
locationId = loc.id;
}
const tagNames: string[] = Array.isArray(tags)
? tags
.map((t: unknown) => String(t).trim())
.filter((t) => t.length > 0)
: [];
// ⬅️ NEU: Parent/Hauptgerät ermitteln (falls angegeben)
let parentDeviceConnect: Prisma.DeviceCreateInput['parentDevice'] | undefined;
if (typeof parentInventoryNumber === 'string') {
const trimmedParent = parentInventoryNumber.trim();
// nicht auf sich selbst zeigen und nicht leer
if (trimmedParent && trimmedParent !== inventoryNumber) {
parentDeviceConnect = {
connect: { inventoryNumber: trimmedParent },
};
}
}
const created = await prisma.device.create({
data: {
inventoryNumber,
name,
manufacturer,
model,
serialNumber: serialNumber ?? null,
productNumber: productNumber ?? null,
comment: comment ?? null,
ipv4Address: ipv4Address ?? null,
ipv6Address: ipv6Address ?? null,
macAddress: macAddress ?? null,
username: username ?? null,
passwordHash: passwordHash ?? null,
loanedTo: loanedTo ?? null,
loanedFrom: loanedFrom ? new Date(loanedFrom) : null,
loanedUntil: loanedUntil ? new Date(loanedUntil) : null,
loanComment: loanComment ?? null,
groupId,
locationId,
createdById: canConnectUser && userId ? userId : null,
...(parentDeviceConnect ? { parentDevice: parentDeviceConnect } : {}),
...(tagNames.length
? {
tags: {
connectOrCreate: tagNames.map((name) => ({
where: { name },
create: { name },
})),
},
}
: {}),
},
include: {
group: true,
location: true,
tags: true,
parentDevice: true,
accessories: true,
createdBy: true,
},
});
const snapshot: Prisma.JsonObject = {
before: null,
after: {
inventoryNumber: created.inventoryNumber,
name: created.name,
manufacturer: created.manufacturer,
model: created.model,
serialNumber: created.serialNumber,
productNumber: created.productNumber,
comment: created.comment,
ipv4Address: created.ipv4Address,
ipv6Address: created.ipv6Address,
macAddress: created.macAddress,
username: created.username,
passwordHash: created.passwordHash,
group: created.group?.name ?? null,
location: created.location?.name ?? null,
tags: created.tags.map((t) => t.name),
loanedTo: created.loanedTo,
loanedFrom: created.loanedFrom
? created.loanedFrom.toISOString()
: null,
loanedUntil: created.loanedUntil
? created.loanedUntil.toISOString()
: null,
loanComment: created.loanComment,
createdAt: created.createdAt.toISOString(),
updatedAt: created.updatedAt.toISOString(),
},
changes: [],
};
await prisma.deviceHistory.create({
data: {
deviceId: created.inventoryNumber,
changeType: 'CREATED',
snapshot,
changedById: canConnectUser && userId ? userId : null,
},
});
const io = (global as any).devicesIo as IOServer | undefined;
if (io) {
io.emit('device:created', {
inventoryNumber: created.inventoryNumber,
name: created.name,
manufacturer: created.manufacturer,
model: created.model,
serialNumber: created.serialNumber,
productNumber: created.productNumber,
comment: created.comment,
ipv4Address: created.ipv4Address,
ipv6Address: created.ipv6Address,
macAddress: created.macAddress,
username: created.username,
group: created.group?.name ?? null,
location: created.location?.name ?? null,
tags: created.tags.map((t) => t.name),
loanedTo: created.loanedTo,
loanedFrom: created.loanedFrom
? created.loanedFrom.toISOString()
: null,
loanedUntil: created.loanedUntil
? created.loanedUntil.toISOString()
: null,
loanComment: created.loanComment,
updatedAt: created.updatedAt.toISOString(),
});
}
return NextResponse.json(
{
inventoryNumber: created.inventoryNumber,
name: created.name,
manufacturer: created.manufacturer,
model: created.model,
serialNumber: created.serialNumber,
productNumber: created.productNumber,
comment: created.comment,
ipv4Address: created.ipv4Address,
ipv6Address: created.ipv6Address,
macAddress: created.macAddress,
username: created.username,
passwordHash: created.passwordHash,
group: created.group?.name ?? null,
location: created.location?.name ?? null,
tags: created.tags.map((t) => t.name),
loanedTo: created.loanedTo,
loanedFrom: created.loanedFrom
? created.loanedFrom.toISOString()
: null,
loanedUntil: created.loanedUntil
? created.loanedUntil.toISOString()
: null,
loanComment: created.loanComment,
// ⬅️ NEU: Parent + Accessories wie im GET
parentInventoryNumber: created.parentDeviceId,
parentName: created.parentDevice?.name ?? null,
accessories: created.accessories.map((a) => ({
inventoryNumber: a.inventoryNumber,
name: a.name,
})),
createdAt: created.createdAt.toISOString(),
updatedAt: created.updatedAt.toISOString(),
},
{ status: 201 },
);
} catch (err) {
console.error('Error in POST /api/devices/[id]', err);
return NextResponse.json(
{ error: 'Interner Serverfehler beim Anlegen des Geräts.' },
{ status: 500 },
);
}
}
export async function PATCH(req: Request, ctx: RouteContext) {
const { id } = await ctx.params;
if (!id) {
return NextResponse.json({ error: 'MISSING_ID' }, { status: 400 });
}
try {
const body = await req.json();
const userId = await getCurrentUserId();
let canConnectUpdatedBy = false;
if (userId) {
const user = await prisma.user.findUnique({
where: { nwkennung: userId }, // ✅ User.nwkennung ist @id
select: { nwkennung: true },
});
if (user) {
canConnectUpdatedBy = true;
} else {
console.warn(
`[PATCH /api/devices/${id}] User mit nwkennung=${userId} nicht gefunden updatedBy wird nicht gesetzt.`,
);
}
}
const existing = await prisma.device.findUnique({
where: { inventoryNumber: id },
include: {
group: true,
location: true,
tags: true,
parentDevice: true,
},
});
if (!existing) {
return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 });
}
const data: Prisma.DeviceUpdateInput = {
name: body.name,
manufacturer: body.manufacturer,
model: body.model,
serialNumber: body.serialNumber ?? null,
productNumber: body.productNumber ?? null,
comment: body.comment ?? null,
ipv4Address: body.ipv4Address ?? null,
ipv6Address: body.ipv6Address ?? null,
macAddress: body.macAddress ?? null,
username: body.username ?? null,
passwordHash: body.passwordHash ?? null,
// Verleih
loanedTo: body.loanedTo ?? null,
loanedFrom: body.loanedFrom ? new Date(body.loanedFrom) : null,
loanedUntil: body.loanedUntil ? new Date(body.loanedUntil) : null,
loanComment: body.loanComment ?? null,
};
// Hauptgerät / Parent
if ('parentInventoryNumber' in body) {
const raw = body.parentInventoryNumber;
if (typeof raw === 'string') {
const trimmed = raw.trim();
if (trimmed.length > 0 && trimmed !== existing.inventoryNumber) {
data.parentDevice = {
connect: { inventoryNumber: trimmed },
};
} else {
// leerer String oder auf sich selbst → trennen
data.parentDevice = { disconnect: true };
}
} else if (raw == null) {
// null / undefined explizit → trennen
data.parentDevice = { disconnect: true };
}
}
if (canConnectUpdatedBy && userId) {
data.updatedBy = {
connect: { nwkennung: userId },
};
}
// Gruppe
if (body.group != null && body.group !== '') {
data.group = {
connectOrCreate: {
where: { name: body.group },
create: { name: body.group },
},
};
} else {
data.group = { disconnect: true };
}
// Standort
if (body.location != null && body.location !== '') {
data.location = {
connectOrCreate: {
where: { name: body.location },
create: { name: body.location },
},
};
} else {
data.location = { disconnect: true };
}
// Tags
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 = {
connectOrCreate: toConnect.map((name) => ({
where: { name },
create: { name },
})),
disconnect: toDisconnect.map((name) => ({ name })),
};
}
const updated = await prisma.device.update({
where: { inventoryNumber: id },
data,
include: {
group: true,
location: true,
tags: true,
parentDevice: 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];
const changes: {
field:
| TrackedField
| 'group'
| 'location'
| 'tags'
| 'loanedTo'
| 'loanedFrom'
| 'loanedUntil'
| 'loanComment'
| 'parentDevice';
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
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
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,
});
}
// Verleih-Diffs
const beforeLoanedTo = existing.loanedTo ?? null;
const afterLoanedTo = updated.loanedTo ?? null;
if (beforeLoanedTo !== afterLoanedTo) {
changes.push({
field: 'loanedTo',
before: beforeLoanedTo,
after: afterLoanedTo,
});
}
const beforeLoanedFrom = existing.loanedFrom
? existing.loanedFrom.toISOString()
: null;
const afterLoanedFrom = updated.loanedFrom
? updated.loanedFrom.toISOString()
: null;
if (beforeLoanedFrom !== afterLoanedFrom) {
changes.push({
field: 'loanedFrom',
before: beforeLoanedFrom,
after: afterLoanedFrom,
});
}
const beforeLoanedUntil = existing.loanedUntil
? existing.loanedUntil.toISOString()
: null;
const afterLoanedUntil = updated.loanedUntil
? updated.loanedUntil.toISOString()
: null;
if (beforeLoanedUntil !== afterLoanedUntil) {
changes.push({
field: 'loanedUntil',
before: beforeLoanedUntil,
after: afterLoanedUntil,
});
}
const beforeLoanComment = existing.loanComment ?? null;
const afterLoanComment = updated.loanComment ?? null;
if (beforeLoanComment !== afterLoanComment) {
changes.push({
field: 'loanComment',
before: beforeLoanComment,
after: afterLoanComment,
});
}
// Parent / Hauptgerät-Diff
const beforeParent =
existing.parentDeviceId != null
? `${existing.parentDeviceId}${
existing.parentDevice?.name ? ' ' + existing.parentDevice.name : ''
}`
: null;
const afterParent =
updated.parentDeviceId != null
? `${updated.parentDeviceId}${
updated.parentDevice?.name ? ' ' + updated.parentDevice.name : ''
}`
: null;
if (beforeParent !== afterParent) {
changes.push({
field: 'parentDevice',
before: beforeParent,
after: afterParent,
});
}
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),
loanedTo: existing.loanedTo,
loanedFrom: existing.loanedFrom
? existing.loanedFrom.toISOString()
: null,
loanedUntil: existing.loanedUntil
? existing.loanedUntil.toISOString()
: null,
loanComment: existing.loanComment,
parentInventoryNumber: existing.parentDeviceId,
parentName: existing.parentDevice?.name ?? null,
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),
loanedTo: updated.loanedTo,
loanedFrom: updated.loanedFrom
? updated.loanedFrom.toISOString()
: null,
loanedUntil: updated.loanedUntil
? updated.loanedUntil.toISOString()
: null,
loanComment: updated.loanComment,
parentInventoryNumber: updated.parentDeviceId,
parentName: updated.parentDevice?.name ?? null,
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: canConnectUpdatedBy && userId ? userId : null,
},
});
}
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,
tags: updated.tags.map((t) => t.name),
loanedTo: updated.loanedTo,
loanedFrom: updated.loanedFrom
? updated.loanedFrom.toISOString()
: null,
loanedUntil: updated.loanedUntil
? updated.loanedUntil.toISOString()
: null,
loanComment: updated.loanComment,
parentInventoryNumber: updated.parentDeviceId,
parentName: updated.parentDevice?.name ?? null,
updatedAt: updated.updatedAt.toISOString(),
});
}
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,
tags: updated.tags.map((t) => t.name),
loanedTo: updated.loanedTo,
loanedFrom: updated.loanedFrom
? updated.loanedFrom.toISOString()
: null,
loanedUntil: updated.loanedUntil
? updated.loanedUntil.toISOString()
: null,
loanComment: updated.loanComment,
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 });
}
}
export async function DELETE(_req: Request, ctx: RouteContext) {
const { id } = await ctx.params;
if (!id) {
return NextResponse.json({ error: 'MISSING_ID' }, { status: 400 });
}
try {
const existing = await prisma.device.findUnique({
where: { inventoryNumber: id },
include: {
group: true,
location: true,
tags: true,
accessories: true,
},
});
if (!existing) {
return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 });
}
// ❗ Optional: Hauptgerät mit Zubehör nicht löschen
if (existing.accessories && existing.accessories.length > 0) {
return NextResponse.json(
{
error: 'HAS_ACCESSORIES',
message:
'Das Gerät hat noch Zubehör und kann nicht gelöscht werden. Entferne oder verschiebe zuerst das Zubehör.',
},
{ status: 409 },
);
}
// User für History ermitteln
const userId = await getCurrentUserId();
let changedById: string | null = null;
if (userId) {
const user = await prisma.user.findUnique({
where: { nwkennung: userId },
select: { nwkennung: true },
});
if (user) {
changedById = userId;
} else {
console.warn(
`[DELETE /api/devices/${id}] User mit nwkennung=${userId} nicht gefunden changedById wird nicht gesetzt.`,
);
}
}
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),
loanedTo: existing.loanedTo,
loanedFrom: existing.loanedFrom
? existing.loanedFrom.toISOString()
: null,
loanedUntil: existing.loanedUntil
? existing.loanedUntil.toISOString()
: null,
loanComment: existing.loanComment,
createdAt: existing.createdAt.toISOString(),
updatedAt: existing.updatedAt.toISOString(),
},
after: null,
changes: [],
};
// History + Delete in einer Transaktion
await prisma.$transaction([
prisma.deviceHistory.create({
data: {
deviceId: existing.inventoryNumber,
changeType: 'DELETED',
snapshot,
changedById,
},
}),
prisma.device.delete({
where: { inventoryNumber: id },
}),
]);
// Socket.IO Event feuern
const io = (global as any).devicesIo as IOServer | undefined;
if (io) {
io.emit('device:deleted', { inventoryNumber: id });
}
return NextResponse.json({ ok: true });
} catch (err) {
console.error('[DELETE /api/devices/[id]]', err);
return NextResponse.json(
{ error: 'INTERNAL_ERROR' },
{ status: 500 },
);
}
}