diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 666fdbb..de0adeb 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -14,6 +14,7 @@ import { errorHandler } from './middleware/error.js'; import authRoutes from './routes/auth.js'; import userRoutes from './routes/users.js'; import manufacturerRoutes from './routes/manufacturers.js'; +import partModelRoutes from './routes/part-models.js'; import siteRoutes from './routes/sites.js'; import roomRoutes from './routes/rooms.js'; import binRoutes from './routes/bins.js'; @@ -79,6 +80,7 @@ app.use('/api/auth', authLimiter, authRoutes); app.use('/api', requireCsrf); app.use('/api/users', userRoutes); app.use('/api/manufacturers', manufacturerRoutes); +app.use('/api/part-models', partModelRoutes); app.use('/api/sites', siteRoutes); app.use('/api/rooms', roomRoutes); app.use('/api/bins', binRoutes); diff --git a/apps/api/src/controllers/hosts.ts b/apps/api/src/controllers/hosts.ts index a0080ed..be8e229 100644 --- a/apps/api/src/controllers/hosts.ts +++ b/apps/api/src/controllers/hosts.ts @@ -48,6 +48,21 @@ export async function update(req: Request<{ id: string }>, res: Response, next: } } +export async function listDeployedParts( + req: Request<{ id: string }>, + res: Response, + next: NextFunction, +) { + try { + const parts = await prisma.$transaction((tx) => + svc.listDeployedParts(tx, req.params.id), + ); + res.json(parts); + } catch (err) { + next(err); + } +} + export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) { try { await prisma.$transaction((tx) => svc.remove(tx, req.params.id)); diff --git a/apps/api/src/controllers/part-models.ts b/apps/api/src/controllers/part-models.ts new file mode 100644 index 0000000..8de9cac --- /dev/null +++ b/apps/api/src/controllers/part-models.ts @@ -0,0 +1,58 @@ +import type { NextFunction, Request, Response } from 'express'; +import { prisma } from '@vector/db'; +import type { + CreatePartModelRequest, + PartModelListQuery, + UpdatePartModelRequest, +} from '@vector/shared'; +import * as svc from '../services/part-models.js'; +import { errors } from '../lib/http-error.js'; + +export async function list(req: Request, res: Response, next: NextFunction) { + try { + const q = req.validated!.query as PartModelListQuery; + const result = await prisma.$transaction((tx) => svc.list(tx, q)); + res.json(result); + } catch (err) { + next(err); + } +} + +export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const model = await prisma.$transaction((tx) => svc.get(tx, req.params.id)); + if (!model) throw errors.notFound('Part model'); + res.json(model); + } catch (err) { + next(err); + } +} + +export async function create(req: Request, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as CreatePartModelRequest; + const model = await prisma.$transaction((tx) => svc.create(tx, input)); + res.status(201).json(model); + } catch (err) { + next(err); + } +} + +export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as UpdatePartModelRequest; + const model = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input)); + res.json(model); + } catch (err) { + next(err); + } +} + +export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + await prisma.$transaction((tx) => svc.remove(tx, req.params.id)); + res.status(204).end(); + } catch (err) { + next(err); + } +} diff --git a/apps/api/src/controllers/repairs.ts b/apps/api/src/controllers/repairs.ts index 854de66..217c19a 100644 --- a/apps/api/src/controllers/repairs.ts +++ b/apps/api/src/controllers/repairs.ts @@ -1,7 +1,9 @@ import type { NextFunction, Request, Response } from 'express'; import { prisma } from '@vector/db'; import type { + CreateRepairCommentRequest, CreateRepairJobRequest, + RepairCommentListQuery, RepairJobListQuery, UpdateRepairJobRequest, } from '@vector/shared'; @@ -28,19 +30,6 @@ export async function get(req: Request<{ id: string }>, res: Response, next: Nex } } -export async function listForPart( - req: Request<{ id: string }>, - res: Response, - next: NextFunction, -) { - try { - const repairs = await prisma.$transaction((tx) => svc.listForPart(tx, req.params.id)); - res.json(repairs); - } catch (err) { - next(err); - } -} - export async function create(req: Request, res: Response, next: NextFunction) { try { const input = req.validated!.body as CreateRepairJobRequest; @@ -73,3 +62,33 @@ export async function remove(req: Request<{ id: string }>, res: Response, next: next(err); } } + +export async function listComments( + req: Request<{ id: string }>, + res: Response, + next: NextFunction, +) { + try { + const q = req.validated!.query as RepairCommentListQuery; + const result = await prisma.$transaction((tx) => svc.listComments(tx, req.params.id, q)); + res.json(result); + } catch (err) { + next(err); + } +} + +export async function addComment( + req: Request<{ id: string }>, + res: Response, + next: NextFunction, +) { + try { + const input = req.validated!.body as CreateRepairCommentRequest; + const comment = await prisma.$transaction((tx) => + svc.addComment(tx, req.params.id, input, req.user ?? null), + ); + res.status(201).json(comment); + } catch (err) { + next(err); + } +} diff --git a/apps/api/src/routes/hosts.ts b/apps/api/src/routes/hosts.ts index c24ad05..f5c16fc 100644 --- a/apps/api/src/routes/hosts.ts +++ b/apps/api/src/routes/hosts.ts @@ -13,6 +13,7 @@ const router = Router(); router.get('/', requireAuth, validate('query', HostListQuery), ctrl.list); router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateHostRequest), ctrl.create); router.get('/:id', requireAuth, ctrl.get); +router.get('/:id/deployed-parts', requireAuth, ctrl.listDeployedParts); router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateHostRequest), ctrl.update); router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove); diff --git a/apps/api/src/routes/part-models.ts b/apps/api/src/routes/part-models.ts new file mode 100644 index 0000000..8ab7298 --- /dev/null +++ b/apps/api/src/routes/part-models.ts @@ -0,0 +1,31 @@ +import { Router } from 'express'; +import { + CreatePartModelRequest, + PartModelListQuery, + UpdatePartModelRequest, +} from '@vector/shared'; +import * as ctrl from '../controllers/part-models.js'; +import { requireAuth, requireRole } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; + +const router = Router(); + +router.get('/', requireAuth, validate('query', PartModelListQuery), ctrl.list); +router.get('/:id', requireAuth, ctrl.get); +router.post( + '/', + requireAuth, + requireRole('ADMIN'), + validate('body', CreatePartModelRequest), + ctrl.create, +); +router.patch( + '/:id', + requireAuth, + requireRole('ADMIN'), + validate('body', UpdatePartModelRequest), + ctrl.update, +); +router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove); + +export default router; diff --git a/apps/api/src/routes/parts.ts b/apps/api/src/routes/parts.ts index d3ae6fa..4320b19 100644 --- a/apps/api/src/routes/parts.ts +++ b/apps/api/src/routes/parts.ts @@ -9,7 +9,6 @@ import { } from '@vector/shared'; import * as ctrl from '../controllers/parts.js'; import * as tagsCtrl from '../controllers/tags.js'; -import * as repairsCtrl from '../controllers/repairs.js'; import { requireAuth, requireRole } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; @@ -27,6 +26,4 @@ router.get('/:id/tags', requireAuth, tagsCtrl.listForPart); router.post('/:id/tags', requireAuth, validate('body', AssignTagsRequest), tagsCtrl.assignToPart); router.delete('/:id/tags/:tagId', requireAuth, tagsCtrl.unassignFromPart); -router.get('/:id/repairs', requireAuth, repairsCtrl.listForPart); - export default router; diff --git a/apps/api/src/routes/repairs.ts b/apps/api/src/routes/repairs.ts index a08ad4b..b6a600e 100644 --- a/apps/api/src/routes/repairs.ts +++ b/apps/api/src/routes/repairs.ts @@ -1,6 +1,8 @@ import { Router } from 'express'; import { + CreateRepairCommentRequest, CreateRepairJobRequest, + RepairCommentListQuery, RepairJobListQuery, UpdateRepairJobRequest, } from '@vector/shared'; @@ -16,4 +18,17 @@ router.get('/:id', requireAuth, ctrl.get); router.patch('/:id', requireAuth, validate('body', UpdateRepairJobRequest), ctrl.update); router.delete('/:id', requireAuth, ctrl.remove); +router.get( + '/:id/comments', + requireAuth, + validate('query', RepairCommentListQuery), + ctrl.listComments, +); +router.post( + '/:id/comments', + requireAuth, + validate('body', CreateRepairCommentRequest), + ctrl.addComment, +); + export default router; diff --git a/apps/api/src/services/analytics.test.ts b/apps/api/src/services/analytics.test.ts index 7a89fe8..2942869 100644 --- a/apps/api/src/services/analytics.test.ts +++ b/apps/api/src/services/analytics.test.ts @@ -12,10 +12,16 @@ function makeTx(args: { state: string; binId: string | null; createdAt: Date; - manufacturerId: string; + partModelId: string; }[]; openRepairs: number; - eolManufacturers: { id: string; name: string; eolDate: Date | null }[]; + eolPartModels: { + id: string; + mpn: string; + eolDate: Date | null; + manufacturerId: string; + manufacturer: { name: string }; + }[]; bins: { id: string; name: string; room: { name: string; site: { name: string } } }[]; }): Tx { const tx = { @@ -32,8 +38,8 @@ function makeTx(args: { repairJob: { count: async () => args.openRepairs, }, - manufacturer: { - findMany: async () => args.eolManufacturers, + partModel: { + findMany: async () => args.eolPartModels, }, bin: { findMany: async () => args.bins, @@ -55,7 +61,7 @@ describe('analytics.dashboard', () => { ], parts: [], openRepairs: 4, - eolManufacturers: [], + eolPartModels: [], bins: [], }); @@ -73,13 +79,13 @@ describe('analytics.dashboard', () => { partCount: 4, stateRows: [], parts: [ - { id: 'p1', state: 'SPARE', binId: null, createdAt: daysAgo(10), manufacturerId: 'm' }, - { id: 'p2', state: 'SPARE', binId: null, createdAt: daysAgo(60), manufacturerId: 'm' }, - { id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), manufacturerId: 'm' }, - { id: 'p4', state: 'SPARE', binId: null, createdAt: daysAgo(900), manufacturerId: 'm' }, + { id: 'p1', state: 'SPARE', binId: null, createdAt: daysAgo(10), partModelId: 'pm' }, + { id: 'p2', state: 'SPARE', binId: null, createdAt: daysAgo(60), partModelId: 'pm' }, + { id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), partModelId: 'pm' }, + { id: 'p4', state: 'SPARE', binId: null, createdAt: daysAgo(900), partModelId: 'pm' }, ], openRepairs: 0, - eolManufacturers: [], + eolPartModels: [], bins: [], }); @@ -98,13 +104,13 @@ describe('analytics.dashboard', () => { partCount: 4, stateRows: [], parts: [ - { id: '1', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), manufacturerId: 'm' }, - { id: '2', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), manufacturerId: 'm' }, - { id: '3', state: 'SPARE', binId: 'b2', createdAt: daysAgo(1), manufacturerId: 'm' }, - { id: '4', state: 'SPARE', binId: null, createdAt: daysAgo(1), manufacturerId: 'm' }, + { id: '1', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), partModelId: 'pm' }, + { id: '2', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), partModelId: 'pm' }, + { id: '3', state: 'SPARE', binId: 'b2', createdAt: daysAgo(1), partModelId: 'pm' }, + { id: '4', state: 'SPARE', binId: null, createdAt: daysAgo(1), partModelId: 'pm' }, ], openRepairs: 0, - eolManufacturers: [], + eolPartModels: [], bins: [ { id: 'b1', name: 'A1', room: { name: 'Lab', site: { name: 'HQ' } } }, { id: 'b2', name: 'B2', room: { name: 'Lab', site: { name: 'HQ' } } }, @@ -118,27 +124,53 @@ describe('analytics.dashboard', () => { ]); }); - it('flags manufacturers whose EOL has passed and have deployed parts', async () => { + it('flags part models whose EOL has passed and have deployed parts', async () => { const tx = makeTx({ partCount: 3, stateRows: [], parts: [ - { id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm1' }, - { id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm1' }, - { id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm2' }, + { id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' }, + { id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' }, + { id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' }, ], openRepairs: 0, - eolManufacturers: [ - { id: 'm1', name: 'Acme', eolDate: daysAgo(30) }, - { id: 'm2', name: 'Beta', eolDate: daysAgo(10) }, - { id: 'm3', name: 'Gamma', eolDate: daysAgo(5) }, + eolPartModels: [ + { + id: 'pm1', + mpn: 'ACM-100', + eolDate: daysAgo(30), + manufacturerId: 'm1', + manufacturer: { name: 'Acme' }, + }, + { + id: 'pm2', + mpn: 'BET-200', + eolDate: daysAgo(10), + manufacturerId: 'm2', + manufacturer: { name: 'Beta' }, + }, + { + id: 'pm3', + mpn: 'GAM-300', + eolDate: daysAgo(5), + manufacturerId: 'm3', + manufacturer: { name: 'Gamma' }, + }, ], bins: [], }); const r = await dashboard(tx); - expect(r.deployedPastEol.map((m) => m.name)).toEqual(['Acme', 'Beta']); - expect(r.deployedPastEol[0]).toMatchObject({ manufacturerId: 'm1', deployedCount: 2 }); - expect(r.deployedPastEol[1]).toMatchObject({ manufacturerId: 'm2', deployedCount: 1 }); + expect(r.deployedPastEol.map((m) => m.mpn)).toEqual(['ACM-100', 'BET-200']); + expect(r.deployedPastEol[0]).toMatchObject({ + partModelId: 'pm1', + manufacturerName: 'Acme', + deployedCount: 2, + }); + expect(r.deployedPastEol[1]).toMatchObject({ + partModelId: 'pm2', + manufacturerName: 'Beta', + deployedCount: 1, + }); }); }); diff --git a/apps/api/src/services/analytics.ts b/apps/api/src/services/analytics.ts index fa41d39..d899e6e 100644 --- a/apps/api/src/services/analytics.ts +++ b/apps/api/src/services/analytics.ts @@ -13,7 +13,7 @@ const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [ ]; export async function dashboard(tx: Tx): Promise { - const [totalParts, stateRows, parts, openRepairs, manufacturersWithEol] = await Promise.all([ + const [totalParts, stateRows, parts, openRepairs, partModelsWithEol] = await Promise.all([ tx.part.count(), tx.part.groupBy({ by: ['state'], @@ -21,12 +21,18 @@ export async function dashboard(tx: Tx): Promise { _sum: { price: true }, }), tx.part.findMany({ - select: { id: true, state: true, binId: true, createdAt: true, manufacturerId: true }, + select: { id: true, state: true, binId: true, createdAt: true, partModelId: true }, }), tx.repairJob.count({ where: { status: { in: ['PENDING', 'IN_PROGRESS'] } } }), - tx.manufacturer.findMany({ + tx.partModel.findMany({ where: { eolDate: { not: null, lte: new Date() } }, - select: { id: true, name: true, eolDate: true }, + select: { + id: true, + mpn: true, + eolDate: true, + manufacturerId: true, + manufacturer: { select: { name: true } }, + }, }), ]); @@ -69,17 +75,19 @@ export async function dashboard(tx: Tx): Promise { count: binCounts.get(id) ?? 0, })); - const deployedByMfg = new Map(); + const deployedByModel = new Map(); for (const part of parts) { if (part.state !== 'DEPLOYED') continue; - deployedByMfg.set(part.manufacturerId, (deployedByMfg.get(part.manufacturerId) ?? 0) + 1); + deployedByModel.set(part.partModelId, (deployedByModel.get(part.partModelId) ?? 0) + 1); } - const deployedPastEol = manufacturersWithEol + const deployedPastEol = partModelsWithEol .map((m) => ({ - manufacturerId: m.id, - name: m.name, - eolDate: m.eolDate ? m.eolDate.toISOString() : null, - deployedCount: deployedByMfg.get(m.id) ?? 0, + partModelId: m.id, + mpn: m.mpn, + manufacturerId: m.manufacturerId, + manufacturerName: m.manufacturer.name, + eolDate: m.eolDate ? m.eolDate.toISOString() : '', + deployedCount: deployedByModel.get(m.id) ?? 0, })) .filter((m) => m.deployedCount > 0) .sort((a, b) => b.deployedCount - a.deployedCount); diff --git a/apps/api/src/services/hosts.ts b/apps/api/src/services/hosts.ts index a020e0c..cb6ebde 100644 --- a/apps/api/src/services/hosts.ts +++ b/apps/api/src/services/hosts.ts @@ -7,12 +7,18 @@ import type { import { errors } from '../lib/http-error.js'; import type { Tx } from './types.js'; +function mapUniqueViolation(target: unknown): string { + if (Array.isArray(target) && target.includes('assetId')) return 'Asset ID already in use'; + return 'Host name already exists'; +} + export async function list(tx: Tx, q: HostListQuery) { const { page, pageSize, q: search } = q; const where: Prisma.HostWhereInput = search ? { OR: [ { name: { contains: search } }, + { assetId: { contains: search } }, { location: { contains: search } }, ], } @@ -33,10 +39,19 @@ export function get(tx: Tx, id: string) { return tx.host.findUnique({ where: { id } }); } +export function listDeployedParts(tx: Tx, hostId: string) { + return tx.part.findMany({ + where: { hostId, state: 'DEPLOYED' }, + orderBy: { serialNumber: 'asc' }, + include: { partModel: true, manufacturer: true }, + }); +} + export async function create(tx: Tx, input: CreateHostRequest) { try { return await tx.host.create({ data: { + assetId: input.assetId, name: input.name, location: input.location ?? null, notes: input.notes ?? null, @@ -44,7 +59,7 @@ export async function create(tx: Tx, input: CreateHostRequest) { }); } catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { - throw errors.conflict('Host name already exists'); + throw errors.conflict(mapUniqueViolation(err.meta?.target)); } throw err; } @@ -52,6 +67,7 @@ export async function create(tx: Tx, input: CreateHostRequest) { export async function update(tx: Tx, id: string, input: UpdateHostRequest) { const data: Prisma.HostUpdateInput = {}; + if (input.assetId !== undefined) data.assetId = input.assetId; if (input.name !== undefined) data.name = input.name; if (input.location !== undefined) data.location = input.location; if (input.notes !== undefined) data.notes = input.notes; @@ -60,7 +76,7 @@ export async function update(tx: Tx, id: string, input: UpdateHostRequest) { } catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError) { if (err.code === 'P2025') throw errors.notFound('Host'); - if (err.code === 'P2002') throw errors.conflict('Host name already exists'); + if (err.code === 'P2002') throw errors.conflict(mapUniqueViolation(err.meta?.target)); } throw err; } @@ -70,8 +86,9 @@ export async function remove(tx: Tx, id: string) { try { await tx.host.delete({ where: { id } }); } catch (err) { - if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { - throw errors.notFound('Host'); + if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === 'P2025') throw errors.notFound('Host'); + if (err.code === 'P2003') throw errors.conflict('Cannot delete: host has repairs assigned'); } throw err; } diff --git a/apps/api/src/services/manufacturers.ts b/apps/api/src/services/manufacturers.ts index b13b060..82cc95e 100644 --- a/apps/api/src/services/manufacturers.ts +++ b/apps/api/src/services/manufacturers.ts @@ -22,12 +22,7 @@ export async function list(tx: Tx, q: PaginationQuery) { export async function create(tx: Tx, input: CreateManufacturerRequest) { try { - return await tx.manufacturer.create({ - data: { - name: input.name, - eolDate: input.eolDate ? new Date(input.eolDate) : null, - }, - }); + return await tx.manufacturer.create({ data: { name: input.name } }); } catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { throw errors.conflict('Manufacturer already exists'); @@ -40,7 +35,6 @@ export async function update(tx: Tx, id: string, input: UpdateManufacturerReques try { const data: Prisma.ManufacturerUpdateInput = {}; if (input.name !== undefined) data.name = input.name; - if (input.eolDate !== undefined) data.eolDate = input.eolDate ? new Date(input.eolDate) : null; return await tx.manufacturer.update({ where: { id }, data }); } catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/apps/api/src/services/part-models.ts b/apps/api/src/services/part-models.ts new file mode 100644 index 0000000..9f26f87 --- /dev/null +++ b/apps/api/src/services/part-models.ts @@ -0,0 +1,132 @@ +import { Prisma } from '@vector/db'; +import type { + CreatePartModelRequest, + PartModelListQuery, + UpdatePartModelRequest, +} from '@vector/shared'; +import { errors } from '../lib/http-error.js'; +import type { Tx } from './types.js'; + +const partModelInclude = { + manufacturer: true, + _count: { select: { parts: true } }, +} satisfies Prisma.PartModelInclude; + +export async function list(tx: Tx, q: PartModelListQuery) { + const { page, pageSize, manufacturerId, q: search, eolBefore } = q; + const where: Prisma.PartModelWhereInput = {}; + if (manufacturerId) where.manufacturerId = manufacturerId; + if (search) where.mpn = { contains: search }; + if (eolBefore) where.eolDate = { lte: new Date(eolBefore) }; + + const [data, total] = await Promise.all([ + tx.partModel.findMany({ + where, + orderBy: [{ manufacturer: { name: 'asc' } }, { mpn: 'asc' }], + include: partModelInclude, + skip: (page - 1) * pageSize, + take: pageSize, + }), + tx.partModel.count({ where }), + ]); + return { data, page, pageSize, total }; +} + +export function get(tx: Tx, id: string) { + return tx.partModel.findUnique({ where: { id }, include: partModelInclude }); +} + +export async function create(tx: Tx, input: CreatePartModelRequest) { + try { + return await tx.partModel.create({ + data: { + manufacturerId: input.manufacturerId, + mpn: input.mpn, + eolDate: input.eolDate ? new Date(input.eolDate) : null, + notes: input.notes ?? null, + }, + include: partModelInclude, + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === 'P2002') throw errors.conflict('MPN already exists for this manufacturer'); + if (err.code === 'P2003') throw errors.badRequest('Manufacturer does not exist'); + } + throw err; + } +} + +export async function update(tx: Tx, id: string, input: UpdatePartModelRequest) { + const data: Prisma.PartModelUpdateInput = {}; + if (input.manufacturerId !== undefined) { + data.manufacturer = { connect: { id: input.manufacturerId } }; + } + if (input.mpn !== undefined) data.mpn = input.mpn; + if (input.eolDate !== undefined) { + data.eolDate = input.eolDate ? new Date(input.eolDate) : null; + } + if (input.notes !== undefined) data.notes = input.notes; + try { + return await tx.partModel.update({ where: { id }, data, include: partModelInclude }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === 'P2025') throw errors.notFound('Part model'); + if (err.code === 'P2002') throw errors.conflict('MPN already exists for this manufacturer'); + } + throw err; + } +} + +export async function remove(tx: Tx, id: string) { + try { + await tx.partModel.delete({ where: { id } }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === 'P2025') throw errors.notFound('Part model'); + if (err.code === 'P2003') { + throw errors.conflict('Cannot delete: part model has parts assigned'); + } + } + throw err; + } +} + +// Returns an existing PartModel for (manufacturerId, mpn) or creates one on the fly. +// Used by the parts service so a create/update with { manufacturerId, mpn } shorthand +// transparently provisions a catalog row. +export async function upsertByMpn( + tx: Tx, + input: { manufacturerId: string; mpn: string }, +) { + const existing = await tx.partModel.findUnique({ + where: { + manufacturerId_mpn: { + manufacturerId: input.manufacturerId, + mpn: input.mpn, + }, + }, + }); + if (existing) return existing; + try { + return await tx.partModel.create({ + data: { manufacturerId: input.manufacturerId, mpn: input.mpn }, + }); + } catch (err) { + // Lost the race; fetch the row that won. + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + const winner = await tx.partModel.findUnique({ + where: { + manufacturerId_mpn: { + manufacturerId: input.manufacturerId, + mpn: input.mpn, + }, + }, + }); + if (winner) return winner; + } + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2003') { + throw errors.badRequest('Manufacturer does not exist'); + } + throw err; + } +} diff --git a/apps/api/src/services/parts.ts b/apps/api/src/services/parts.ts index 90ee3b3..6a173db 100644 --- a/apps/api/src/services/parts.ts +++ b/apps/api/src/services/parts.ts @@ -6,13 +6,16 @@ import type { UpdatePartRequest, } from '@vector/shared'; import { errors } from '../lib/http-error.js'; +import * as partModelsSvc from './part-models.js'; import * as tagsSvc from './tags.js'; import type { Actor, Tx } from './types.js'; const partInclude = { manufacturer: true, + partModel: true, bin: { include: { room: { include: { site: true } } } }, category: true, + host: true, tags: { include: { tag: true } }, } satisfies Prisma.PartInclude; @@ -40,26 +43,51 @@ function flattenTags(part: PartWithRelations): PartWithPath { return out; } +// Resolves a CreatePartRequest's partModelId — either the explicit one passed in, or looked up +// via (manufacturerId, mpn) shorthand that auto-creates a PartModel catalog row on first use. +// Exactly one of those two forms is required; the zod schema enforces that at the boundary. +async function resolvePartModel( + tx: Tx, + input: { partModelId?: string; manufacturerId?: string; mpn?: string }, +): Promise<{ partModelId: string; manufacturerId: string }> { + if (input.partModelId) { + const pm = await tx.partModel.findUnique({ where: { id: input.partModelId } }); + if (!pm) throw errors.badRequest('Part model does not exist'); + return { partModelId: pm.id, manufacturerId: pm.manufacturerId }; + } + if (input.manufacturerId && input.mpn) { + const pm = await partModelsSvc.upsertByMpn(tx, { + manufacturerId: input.manufacturerId, + mpn: input.mpn, + }); + return { partModelId: pm.id, manufacturerId: pm.manufacturerId }; + } + throw errors.badRequest('Provide partModelId or both manufacturerId and mpn'); +} + function buildWhere(q: PartListQuery): Prisma.PartWhereInput { const where: Prisma.PartWhereInput = {}; if (q.state) where.state = q.state; if (q.binId) where.binId = q.binId; + if (q.hostId) where.hostId = q.hostId; if (q.manufacturerId) where.manufacturerId = q.manufacturerId; + if (q.partModelId) where.partModelId = q.partModelId; if (q.categoryId) where.categoryId = q.categoryId; - if (q.mpn) where.mpn = { contains: q.mpn }; if (q.serialNumber) where.serialNumber = { contains: q.serialNumber }; if (q.q) { where.OR = [ { serialNumber: { contains: q.q } }, - { mpn: { contains: q.q } }, + { partModel: { mpn: { contains: q.q } } }, { notes: { contains: q.q } }, ]; } if (q.tagId) where.tags = { some: { tagId: q.tagId } }; - if (q.eolOnly) { - // Parts attached to a manufacturer with an EOL date that has already passed. - where.manufacturer = { eolDate: { lt: new Date() } }; - } + + const partModelFilter: Prisma.PartModelWhereInput = {}; + if (q.mpn) partModelFilter.mpn = { contains: q.mpn }; + if (q.eolOnly) partModelFilter.eolDate = { lt: new Date() }; + if (Object.keys(partModelFilter).length > 0) where.partModel = partModelFilter; + return where; } @@ -89,17 +117,23 @@ export async function create( input: CreatePartRequest, actor: Actor | null, ): Promise { + const { partModelId, manufacturerId } = await resolvePartModel(tx, input); + // If caller also supplied manufacturerId explicitly, it must match the part model's. + if (input.manufacturerId && input.manufacturerId !== manufacturerId) { + throw errors.badRequest('manufacturerId does not match the selected part model'); + } + try { const p = await tx.part.create({ data: { serialNumber: input.serialNumber, - mpn: input.mpn, - manufacturerId: input.manufacturerId, + partModelId, + manufacturerId, price: input.price ?? null, state: input.state ?? 'SPARE', binId: input.binId ?? null, + hostId: input.hostId ?? null, categoryId: input.categoryId ?? null, - replacementPartId: input.replacementPartId ?? null, notes: input.notes ?? null, }, include: partInclude, @@ -136,25 +170,26 @@ export async function update( const data: Prisma.PartUpdateInput = {}; if (input.serialNumber !== undefined) data.serialNumber = input.serialNumber; - if (input.mpn !== undefined) data.mpn = input.mpn; - if (input.manufacturerId !== undefined) { - data.manufacturer = { connect: { id: input.manufacturerId } }; + if (input.partModelId !== undefined) { + const pm = await tx.partModel.findUnique({ where: { id: input.partModelId } }); + if (!pm) throw errors.badRequest('Part model does not exist'); + data.partModel = { connect: { id: pm.id } }; + // Keep denormalized manufacturerId consistent with the chosen model. + data.manufacturer = { connect: { id: pm.manufacturerId } }; } if (input.price !== undefined) data.price = input.price; if (input.state !== undefined) data.state = input.state; if (input.binId !== undefined) { data.bin = input.binId ? { connect: { id: input.binId } } : { disconnect: true }; } + if (input.hostId !== undefined) { + data.host = input.hostId ? { connect: { id: input.hostId } } : { disconnect: true }; + } if (input.categoryId !== undefined) { data.category = input.categoryId ? { connect: { id: input.categoryId } } : { disconnect: true }; } - if (input.replacementPartId !== undefined) { - data.replacement = input.replacementPartId - ? { connect: { id: input.replacementPartId } } - : { disconnect: true }; - } if (input.notes !== undefined) data.notes = input.notes; let part: PartWithRelations; @@ -191,14 +226,24 @@ export async function update( newValue: binPath(part.bin), }); } - if (input.mpn !== undefined && input.mpn !== current.mpn) { + if (input.hostId !== undefined && input.hostId !== current.hostId) { + events.push({ + partId: part.id, + userId, + type: 'LOCATION_CHANGED', + field: 'host', + oldValue: current.host?.name ?? null, + newValue: part.host?.name ?? null, + }); + } + if (input.partModelId !== undefined && input.partModelId !== current.partModelId) { events.push({ partId: part.id, userId, type: 'FIELD_UPDATED', - field: 'mpn', - oldValue: current.mpn, - newValue: input.mpn, + field: 'partModel', + oldValue: current.partModel.mpn, + newValue: part.partModel.mpn, }); } if (input.serialNumber !== undefined && input.serialNumber !== current.serialNumber) { @@ -211,16 +256,6 @@ export async function update( newValue: input.serialNumber, }); } - if (input.manufacturerId !== undefined && input.manufacturerId !== current.manufacturerId) { - events.push({ - partId: part.id, - userId, - type: 'FIELD_UPDATED', - field: 'manufacturer', - oldValue: current.manufacturer.name, - newValue: part.manufacturer.name, - }); - } if (input.categoryId !== undefined && input.categoryId !== current.categoryId) { events.push({ partId: part.id, @@ -267,8 +302,11 @@ export async function remove(tx: Tx, id: string) { try { await tx.part.delete({ where: { id } }); } catch (err) { - if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { - throw errors.notFound('Part'); + if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === 'P2025') throw errors.notFound('Part'); + if (err.code === 'P2003') { + throw errors.conflict('Cannot delete: part is referenced by a repair'); + } } throw err; } @@ -295,6 +333,7 @@ export interface BulkPartsInput { ids: string[]; state?: CreatePartRequest['state']; binId?: string | null; + hostId?: string | null; addTagIds?: string[]; removeTagIds?: string[]; } @@ -312,12 +351,13 @@ export async function bulkUpdate(tx: Tx, input: BulkPartsInput, actor: Actor | n const patch: UpdatePartRequest = {}; if (input.state !== undefined) patch.state = input.state; if (input.binId !== undefined) patch.binId = input.binId; + if (input.hostId !== undefined) patch.hostId = input.hostId; if (Object.keys(patch).length > 0) { await update(tx, id, patch, actor); } if (input.addTagIds || input.removeTagIds) { const existing = await tx.partTag.findMany({ where: { partId: id }, select: { tagId: true } }); - let next = new Set(existing.map((r) => r.tagId)); + const next = new Set(existing.map((r) => r.tagId)); (input.addTagIds ?? []).forEach((t) => next.add(t)); (input.removeTagIds ?? []).forEach((t) => next.delete(t)); await tagsSvc.setPartTags(tx, id, [...next], actor); diff --git a/apps/api/src/services/repairs.test.ts b/apps/api/src/services/repairs.test.ts new file mode 100644 index 0000000..44e3334 --- /dev/null +++ b/apps/api/src/services/repairs.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Prisma } from '@vector/db'; +import type { Tx, Actor } from './types.js'; +import { create, addComment, listComments } from './repairs.js'; +import * as partModels from './part-models.js'; +import * as hosts from './hosts.js'; +import { AppError } from '../lib/http-error.js'; + +const actor: Actor = { id: 'user-1', username: 'tech', role: 'ADMIN' }; + +// Fabricate a minimal Prisma.PrismaClientKnownRequestError without requiring a live client. +function prismaError(code: string, meta?: Record) { + return new Prisma.PrismaClientKnownRequestError('simulated', { + code, + clientVersion: 'test', + meta, + }); +} + +describe('repairs.create — host membership', () => { + it('rejects a problem-part that is not on the chosen host', async () => { + const partEventCreateMany = vi.fn(); + const repairCreate = vi.fn(); + + const tx = { + host: { findUnique: async () => ({ id: 'host-1' }) }, + part: { + findMany: async () => [{ id: 'part-a', hostId: 'host-2' }], + }, + repairJob: { create: repairCreate }, + partEvent: { createMany: partEventCreateMany }, + } as unknown as Tx; + + await expect( + create( + tx, + { hostId: 'host-1', problem: 'fan noise', problemPartIds: ['part-a'] }, + actor, + ), + ).rejects.toMatchObject({ status: 400 }); + + expect(repairCreate).not.toHaveBeenCalled(); + expect(partEventCreateMany).not.toHaveBeenCalled(); + }); + + it('succeeds with empty problemPartIds and emits no REPAIR_STARTED events', async () => { + const partEventCreateMany = vi.fn(); + const repairCreate = vi.fn(async () => ({ + id: 'repair-1', + hostId: 'host-1', + problem: 'power drops', + status: 'PENDING', + problemParts: [], + })); + + const tx = { + host: { findUnique: async () => ({ id: 'host-1' }) }, + part: { findMany: async () => [] }, + repairJob: { create: repairCreate }, + partEvent: { createMany: partEventCreateMany }, + } as unknown as Tx; + + const r = await create(tx, { hostId: 'host-1', problem: 'power drops' }, actor); + expect(r.id).toBe('repair-1'); + expect(repairCreate).toHaveBeenCalledOnce(); + expect(partEventCreateMany).not.toHaveBeenCalled(); + }); +}); + +describe('part-models.upsertByMpn', () => { + it('returns the existing row without creating when (manufacturerId, mpn) is taken', async () => { + const existing = { id: 'pm-1', manufacturerId: 'mfr-1', mpn: 'WD-1000', eolDate: null }; + const create = vi.fn(); + const tx = { + partModel: { + findUnique: async () => existing, + create, + }, + } as unknown as Tx; + + const r1 = await partModels.upsertByMpn(tx, { manufacturerId: 'mfr-1', mpn: 'WD-1000' }); + const r2 = await partModels.upsertByMpn(tx, { manufacturerId: 'mfr-1', mpn: 'WD-1000' }); + expect(r1).toBe(existing); + expect(r2).toBe(existing); + expect(create).not.toHaveBeenCalled(); + }); + + it('recovers from a race by re-fetching the winning row on P2002', async () => { + const winner = { id: 'pm-9', manufacturerId: 'mfr-1', mpn: 'WD-1000', eolDate: null }; + let findCall = 0; + const tx = { + partModel: { + findUnique: async () => { + findCall += 1; + if (findCall === 1) return null; + return winner; + }, + create: async () => { + throw prismaError('P2002'); + }, + }, + } as unknown as Tx; + + const r = await partModels.upsertByMpn(tx, { manufacturerId: 'mfr-1', mpn: 'WD-1000' }); + expect(r).toBe(winner); + }); +}); + +describe('hosts.create — assetId uniqueness', () => { + it('surfaces a P2002 on assetId as a 409 with the Asset ID message', async () => { + const tx = { + host: { + create: async () => { + throw prismaError('P2002', { target: ['assetId'] }); + }, + }, + } as unknown as Tx; + + await expect( + hosts.create(tx, { assetId: 'ASSET-001', name: 'rack-1' }), + ).rejects.toMatchObject({ status: 409, message: 'Asset ID already in use' }); + }); + + it('falls through to the name-uniqueness message for other unique targets', async () => { + const tx = { + host: { + create: async () => { + throw prismaError('P2002', { target: ['name'] }); + }, + }, + } as unknown as Tx; + + await expect( + hosts.create(tx, { assetId: 'ASSET-002', name: 'rack-1' }), + ).rejects.toMatchObject({ status: 409, message: 'Host name already exists' }); + }); +}); + +describe('repairs.addComment / listComments', () => { + it('stamps userId from the actor and returns it via listComments', async () => { + const stored: { + id: string; + repairJobId: string; + userId: string | null; + content: string; + createdAt: Date; + user: { id: string; username: string } | null; + }[] = []; + let nextId = 1; + + const tx = { + repairJob: { + findUnique: async ({ include }: { include?: unknown }) => { + if (include) return { id: 'repair-1', problemParts: [] }; + return { id: 'repair-1' }; + }, + }, + repairComment: { + create: async ({ data }: { data: { repairJobId: string; userId: string | null; content: string } }) => { + const row = { + id: `c-${nextId++}`, + repairJobId: data.repairJobId, + userId: data.userId, + content: data.content, + createdAt: new Date(), + user: data.userId ? { id: data.userId, username: actor.username } : null, + }; + stored.push(row); + return row; + }, + findMany: async () => stored, + count: async () => stored.length, + }, + partEvent: { createMany: vi.fn() }, + } as unknown as Tx; + + const created = await addComment(tx, 'repair-1', { content: 'Checked fans' }, actor); + expect(created.userId).toBe(actor.id); + + const page = await listComments(tx, 'repair-1', { page: 1, pageSize: 20 }); + expect(page.total).toBe(1); + expect(page.data[0]?.userId).toBe(actor.id); + expect(page.data[0]?.content).toBe('Checked fans'); + }); + + it('emits a REPAIR_COMMENTED PartEvent for each problem part', async () => { + const partEventCreateMany = vi.fn(); + const tx = { + repairJob: { + findUnique: async () => ({ + id: 'repair-1', + problemParts: [{ partId: 'part-a' }, { partId: 'part-b' }], + }), + }, + repairComment: { + create: async ({ data }: { data: { repairJobId: string; userId: string | null; content: string } }) => ({ + id: 'c-1', + ...data, + createdAt: new Date(), + user: null, + }), + }, + partEvent: { createMany: partEventCreateMany }, + } as unknown as Tx; + + await addComment(tx, 'repair-1', { content: 'ping' }, actor); + expect(partEventCreateMany).toHaveBeenCalledOnce(); + const call = partEventCreateMany.mock.calls[0]![0] as { + data: { partId: string; type: string; userId: string | null }[]; + }; + expect(call.data.map((d) => d.partId)).toEqual(['part-a', 'part-b']); + expect(call.data.every((d) => d.type === 'REPAIR_COMMENTED')).toBe(true); + expect(call.data.every((d) => d.userId === actor.id)).toBe(true); + }); + + it('returns 404 when the repair does not exist', async () => { + const tx = { + repairJob: { findUnique: async () => null }, + } as unknown as Tx; + + await expect( + addComment(tx, 'missing', { content: 'hi' }, actor), + ).rejects.toBeInstanceOf(AppError); + }); +}); diff --git a/apps/api/src/services/repairs.ts b/apps/api/src/services/repairs.ts index c8ab36f..961fef6 100644 --- a/apps/api/src/services/repairs.ts +++ b/apps/api/src/services/repairs.ts @@ -1,6 +1,8 @@ import { Prisma } from '@vector/db'; import type { + CreateRepairCommentRequest, CreateRepairJobRequest, + RepairCommentListQuery, RepairJobListQuery, UpdateRepairJobRequest, } from '@vector/shared'; @@ -8,21 +10,29 @@ import { errors } from '../lib/http-error.js'; import type { Actor, Tx } from './types.js'; const repairInclude = { - part: { - include: { manufacturer: true }, - }, host: true, assignee: { select: { id: true, username: true, email: true, role: true } }, + problemParts: { + include: { + part: { + include: { partModel: true, manufacturer: true }, + }, + }, + }, } satisfies Prisma.RepairJobInclude; +const commentInclude = { + user: { select: { id: true, username: true } }, +} satisfies Prisma.RepairCommentInclude; + export async function list(tx: Tx, q: RepairJobListQuery) { - const { page, pageSize, status, partId, hostId, assigneeId, openOnly } = q; + const { page, pageSize, status, hostId, assigneeId, openOnly, problemPartId } = q; const where: Prisma.RepairJobWhereInput = {}; if (status) where.status = status; - if (partId) where.partId = partId; if (hostId) where.hostId = hostId; if (assigneeId) where.assigneeId = assigneeId; if (openOnly) where.status = { in: ['PENDING', 'IN_PROGRESS'] }; + if (problemPartId) where.problemParts = { some: { partId: problemPartId } }; const [data, total] = await Promise.all([ tx.repairJob.findMany({ @@ -41,45 +51,73 @@ export function get(tx: Tx, id: string) { return tx.repairJob.findUnique({ where: { id }, include: repairInclude }); } -export function listForPart(tx: Tx, partId: string) { +export function listForHost(tx: Tx, hostId: string) { return tx.repairJob.findMany({ - where: { partId }, + where: { hostId }, orderBy: { openedAt: 'desc' }, include: repairInclude, }); } +// Validates that the submitted problem-part ids are all attached to the named host. +// Parts that aren't on the host, or that don't exist, cause the whole repair create/update to fail +// — no silent skipping. Parts can be in any state (a repair can target a SPARE that was tagged as +// faulty during intake); the host-membership check is what matters. +async function validateProblemParts(tx: Tx, hostId: string, partIds: string[] | undefined) { + if (!partIds || partIds.length === 0) return; + const uniqueIds = [...new Set(partIds)]; + const rows = await tx.part.findMany({ + where: { id: { in: uniqueIds } }, + select: { id: true, hostId: true }, + }); + const found = new Map(rows.map((r) => [r.id, r])); + for (const id of uniqueIds) { + const row = found.get(id); + if (!row) throw errors.badRequest(`Part ${id} does not exist`); + if (row.hostId !== hostId) { + throw errors.badRequest(`Part ${id} is not on the selected host`); + } + } +} + export async function create( tx: Tx, input: CreateRepairJobRequest, actor: Actor | null, ) { - const part = await tx.part.findUnique({ where: { id: input.partId } }); - if (!part) throw errors.notFound('Part'); + const host = await tx.host.findUnique({ where: { id: input.hostId } }); + if (!host) throw errors.notFound('Host'); + + await validateProblemParts(tx, input.hostId, input.problemPartIds); try { const repair = await tx.repairJob.create({ data: { - partId: input.partId, - hostId: input.hostId ?? null, + hostId: input.hostId, assigneeId: input.assigneeId ?? null, notes: input.notes ?? null, + problem: input.problem, status: 'PENDING', + problemParts: input.problemPartIds && input.problemPartIds.length > 0 + ? { create: [...new Set(input.problemPartIds)].map((partId) => ({ partId })) } + : undefined, }, include: repairInclude, }); - await tx.partEvent.create({ - data: { - partId: part.id, - userId: actor?.id ?? null, - type: 'REPAIR_STARTED', - newValue: repair.id, - }, - }); + if (input.problemPartIds && input.problemPartIds.length > 0) { + await tx.partEvent.createMany({ + data: [...new Set(input.problemPartIds)].map((partId) => ({ + partId, + userId: actor?.id ?? null, + type: 'REPAIR_STARTED', + newValue: repair.id, + })), + }); + } return repair; } catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2003') { - throw errors.badRequest('Invalid host or assignee id'); + throw errors.badRequest('Invalid host, assignee, or part id'); } throw err; } @@ -91,21 +129,25 @@ export async function update( input: UpdateRepairJobRequest, actor: Actor | null, ) { - const current = await tx.repairJob.findUnique({ where: { id } }); + const current = await tx.repairJob.findUnique({ + where: { id }, + include: { problemParts: { select: { partId: true } }, host: true }, + }); if (!current) throw errors.notFound('Repair'); const data: Prisma.RepairJobUpdateInput = {}; + let terminalTransition: 'COMPLETED' | 'CANCELLED' | null = null; if (input.status !== undefined && input.status !== current.status) { data.status = input.status; - // closedAt follows terminal status transitions. const nowTerminal = input.status === 'COMPLETED' || input.status === 'CANCELLED'; const wasTerminal = current.status === 'COMPLETED' || current.status === 'CANCELLED'; - if (nowTerminal && !wasTerminal) data.closedAt = new Date(); + if (nowTerminal && !wasTerminal) { + data.closedAt = new Date(); + terminalTransition = input.status as 'COMPLETED' | 'CANCELLED'; + } if (!nowTerminal && wasTerminal) data.closedAt = null; } - if (input.hostId !== undefined) { - data.host = input.hostId ? { connect: { id: input.hostId } } : { disconnect: true }; - } + if (input.problem !== undefined) data.problem = input.problem; if (input.assigneeId !== undefined) { data.assignee = input.assigneeId ? { connect: { id: input.assigneeId } } @@ -113,22 +155,56 @@ export async function update( } if (input.notes !== undefined) data.notes = input.notes; + // Problem-parts follow full-replace semantics: the request carries the final desired set. + let addedPartIds: string[] = []; + if (input.problemPartIds !== undefined) { + await validateProblemParts(tx, current.hostId, input.problemPartIds); + const existing = new Set(current.problemParts.map((p) => p.partId)); + const desired = new Set(input.problemPartIds); + addedPartIds = [...desired].filter((p) => !existing.has(p)); + const removed = [...existing].filter((p) => !desired.has(p)); + if (removed.length > 0) { + await tx.repairJobPart.deleteMany({ + where: { repairJobId: id, partId: { in: removed } }, + }); + } + if (addedPartIds.length > 0) { + await tx.repairJobPart.createMany({ + data: addedPartIds.map((partId) => ({ repairJobId: id, partId })), + }); + } + } + const repair = await tx.repairJob.update({ where: { id }, data, include: repairInclude, }); - if (input.status === 'COMPLETED' && current.status !== 'COMPLETED') { - await tx.partEvent.create({ - data: { - partId: repair.partId, - userId: actor?.id ?? null, - type: 'REPAIR_COMPLETED', + const userId = actor?.id ?? null; + if (addedPartIds.length > 0) { + await tx.partEvent.createMany({ + data: addedPartIds.map((partId) => ({ + partId, + userId, + type: 'REPAIR_STARTED', newValue: repair.id, - }, + })), }); } + if (terminalTransition !== null) { + const partIds = repair.problemParts.map((p) => p.partId); + if (partIds.length > 0) { + await tx.partEvent.createMany({ + data: partIds.map((partId) => ({ + partId, + userId, + type: terminalTransition === 'COMPLETED' ? 'REPAIR_COMPLETED' : 'REPAIR_CANCELLED', + newValue: repair.id, + })), + }); + } + } return repair; } @@ -143,3 +219,57 @@ export async function remove(tx: Tx, id: string) { throw err; } } + +export async function listComments(tx: Tx, repairJobId: string, q: RepairCommentListQuery) { + const { page, pageSize } = q; + const repair = await tx.repairJob.findUnique({ where: { id: repairJobId }, select: { id: true } }); + if (!repair) throw errors.notFound('Repair'); + const [data, total] = await Promise.all([ + tx.repairComment.findMany({ + where: { repairJobId }, + orderBy: { createdAt: 'asc' }, + include: commentInclude, + skip: (page - 1) * pageSize, + take: pageSize, + }), + tx.repairComment.count({ where: { repairJobId } }), + ]); + return { data, page, pageSize, total }; +} + +export async function addComment( + tx: Tx, + repairJobId: string, + input: CreateRepairCommentRequest, + actor: Actor | null, +) { + const repair = await tx.repairJob.findUnique({ + where: { id: repairJobId }, + include: { problemParts: { select: { partId: true } } }, + }); + if (!repair) throw errors.notFound('Repair'); + + const comment = await tx.repairComment.create({ + data: { + repairJobId, + userId: actor?.id ?? null, + content: input.content, + }, + include: commentInclude, + }); + + // Surface the comment on each problem-part's timeline so a part owner sees the activity + // without having to navigate through to the repair. + if (repair.problemParts.length > 0) { + await tx.partEvent.createMany({ + data: repair.problemParts.map((p) => ({ + partId: p.partId, + userId: actor?.id ?? null, + type: 'REPAIR_COMMENTED', + newValue: repair.id, + })), + }); + } + + return comment; +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 74202a1..9050396 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -12,7 +12,9 @@ import Parts from './pages/Parts.js'; import PartDetail from './pages/PartDetail.js'; import Locations from './pages/Locations.js'; import Manufacturers from './pages/Manufacturers.js'; +import PartModels from './pages/PartModels.js'; import Repairs from './pages/Repairs.js'; +import RepairDetail from './pages/RepairDetail.js'; import Hosts from './pages/Hosts.js'; import Users from './pages/admin/Users.js'; import Webhooks from './pages/admin/Webhooks.js'; @@ -54,7 +56,9 @@ export default function App() { } /> } /> } /> + } /> } /> + } /> } /> ({ resolver: zodResolver(Schema), - defaultValues: { name: '', location: '', notes: '' }, + defaultValues: { assetId: '', name: '', location: '', notes: '' }, }); useEffect(() => { if (!open) return; form.reset({ + assetId: host?.assetId ?? '', name: host?.name ?? '', location: host?.location ?? '', notes: host?.notes ?? '', @@ -60,12 +62,20 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps const mutation = useMutation({ mutationFn: async (values: Values) => { - const payload = { + if (editing && host) { + return updateHost(host.id, { + assetId: values.assetId, + name: values.name, + location: values.location ? values.location : null, + notes: values.notes ? values.notes : null, + }); + } + return createHost({ + assetId: values.assetId, name: values.name, location: values.location ? values.location : null, notes: values.notes ? values.notes : null, - }; - return editing && host ? updateHost(host.id, payload) : createHost(payload); + }); }, onSuccess: () => { toast.success(editing ? 'Host updated' : 'Host created'); @@ -88,6 +98,19 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
mutation.mutate(v))} className="space-y-3"> + ( + + Asset ID + + + + + + )} + /> Name - + diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index 9a3db9f..7fd8402 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -4,6 +4,7 @@ import { ChevronsLeft, ChevronsRight, LayoutDashboard, + Layers, type LucideIcon, MapPinned, Package, @@ -25,6 +26,7 @@ interface NavItem { const NAV_ITEMS: NavItem[] = [ { to: '/', label: 'Dashboard', icon: LayoutDashboard }, { to: '/parts', label: 'Parts', icon: Package }, + { to: '/part-models', label: 'Part models', icon: Layers }, { to: '/locations', label: 'Locations', icon: MapPinned }, { to: '/manufacturers', label: 'Manufacturers', icon: Boxes }, { to: '/repairs', label: 'Repairs', icon: Wrench }, diff --git a/apps/web/src/components/manufacturers/ManufacturerFormDialog.tsx b/apps/web/src/components/manufacturers/ManufacturerFormDialog.tsx index ccf0361..0549cc6 100644 --- a/apps/web/src/components/manufacturers/ManufacturerFormDialog.tsx +++ b/apps/web/src/components/manufacturers/ManufacturerFormDialog.tsx @@ -15,7 +15,6 @@ import { DialogTitle, Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, @@ -32,11 +31,6 @@ import type { Manufacturer } from '../../lib/api/types.js'; const Schema = z.object({ name: z.string().min(1, 'Required').max(128), - eolDate: z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD') - .or(z.literal('')) - .optional(), }); type Values = z.infer; @@ -46,11 +40,6 @@ interface ManufacturerFormDialogProps { manufacturer?: Manufacturer | null; } -function isoToDateInput(iso: string | null): string { - if (!iso) return ''; - return new Date(iso).toISOString().slice(0, 10); -} - export function ManufacturerFormDialog({ open, onOpenChange, @@ -61,23 +50,17 @@ export function ManufacturerFormDialog({ const form = useForm({ resolver: zodResolver(Schema), - defaultValues: { name: '', eolDate: '' }, + defaultValues: { name: '' }, }); useEffect(() => { if (!open) return; - form.reset({ - name: manufacturer?.name ?? '', - eolDate: isoToDateInput(manufacturer?.eolDate ?? null), - }); + form.reset({ name: manufacturer?.name ?? '' }); }, [open, manufacturer, form]); const mutation = useMutation({ mutationFn: async (values: Values) => { - const payload = { - name: values.name, - eolDate: values.eolDate ? values.eolDate : null, - }; + const payload = { name: values.name }; return editing && manufacturer ? updateManufacturer(manufacturer.id, payload) : createManufacturer(payload); @@ -98,8 +81,8 @@ export function ManufacturerFormDialog({ {editing ? 'Edit manufacturer' : 'New manufacturer'} {editing - ? 'Update this manufacturer. EOL drives replacement alerts on parts.' - : 'Add a manufacturer. Names must be unique.'} + ? 'Update the manufacturer record.' + : 'Add a manufacturer. Names must be unique. EOL is tracked per part model.'} @@ -118,23 +101,6 @@ export function ManufacturerFormDialog({ )} /> - ( - - End-of-life date - - - - - Optional. Parts from this manufacturer will show a replacement alert past this - date. - - - - )} - />