// 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 }; 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 }, ); } }