feat: rework EOL, repairs, and hosts for real workflow
Four domain-model changes driven by exercising the deployed 2.0 build: - EOL moves from manufacturer to MPN via new PartModel catalog table, so alerts fire on the thing that actually ages. - Repairs re-home to Host (required hostId + problem text) with an optional RepairJobPart join for affected parts; drop Part.replacementPartId. - New /repairs/:id detail page with editable problem, part list, and a RepairComment thread (REPAIR_COMMENTED events fan out to each problem part's timeline). - Host.assetId (required, unique) surfaces prominently on the repair page so techs can confirm they're touching the right box. Single destructive migration reshapes existing dev data. All 7 packages typecheck clean; 30 API tests pass (9 new covering host membership, upsertByMpn idempotency + race, assetId 409, comment userId stamping). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [
|
||||
];
|
||||
|
||||
export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
|
||||
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<DashboardAnalytics> {
|
||||
_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<DashboardAnalytics> {
|
||||
count: binCounts.get(id) ?? 0,
|
||||
}));
|
||||
|
||||
const deployedByMfg = new Map<string, number>();
|
||||
const deployedByModel = new Map<string, number>();
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<PartWithPath> {
|
||||
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);
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user