chore: initial Vector 2.0 monorepo
Ground-up TypeScript rewrite of the Vector hardware parts inventory
system. Ships the full roadmap (Phases 0-8) in one initial commit:
- pnpm + Turbo monorepo: apps/{api,web,e2e}, packages/{db,shared,ui,config}
- Express 5 + Prisma 5 + zod validation + JWT w/ refresh-token rotation
- React 19 + Vite + shadcn/ui + TanStack Query/Table + nuqs URL state
- Repair/RMA, tags, bulk ops, saved views, CSV audit export
- Analytics dashboard on Recharts + EOL tracking
- Signed webhook subscriptions (HMAC-SHA256) with in-process emitter
- Vitest unit tests (shared schemas, api services/helpers) + Playwright skeleton
- Gitea Actions CI (lint, typecheck, test+coverage, build) + Renovate
Deferred follow-ups: Postgres cutover (data-migration script ready),
BullMQ worker for webhook delivery, @react-pdf PDF export, CSV import wizard.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { Prisma } from '@vector/db';
|
||||
import type {
|
||||
CreateRepairJobRequest,
|
||||
RepairJobListQuery,
|
||||
UpdateRepairJobRequest,
|
||||
} from '@vector/shared';
|
||||
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 } },
|
||||
} satisfies Prisma.RepairJobInclude;
|
||||
|
||||
export async function list(tx: Tx, q: RepairJobListQuery) {
|
||||
const { page, pageSize, status, partId, hostId, assigneeId, openOnly } = 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'] };
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
tx.repairJob.findMany({
|
||||
where,
|
||||
orderBy: [{ status: 'asc' }, { openedAt: 'desc' }],
|
||||
include: repairInclude,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
tx.repairJob.count({ where }),
|
||||
]);
|
||||
return { data, page, pageSize, total };
|
||||
}
|
||||
|
||||
export function get(tx: Tx, id: string) {
|
||||
return tx.repairJob.findUnique({ where: { id }, include: repairInclude });
|
||||
}
|
||||
|
||||
export function listForPart(tx: Tx, partId: string) {
|
||||
return tx.repairJob.findMany({
|
||||
where: { partId },
|
||||
orderBy: { openedAt: 'desc' },
|
||||
include: repairInclude,
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
try {
|
||||
const repair = await tx.repairJob.create({
|
||||
data: {
|
||||
partId: input.partId,
|
||||
hostId: input.hostId ?? null,
|
||||
assigneeId: input.assigneeId ?? null,
|
||||
notes: input.notes ?? null,
|
||||
status: 'PENDING',
|
||||
},
|
||||
include: repairInclude,
|
||||
});
|
||||
await tx.partEvent.create({
|
||||
data: {
|
||||
partId: part.id,
|
||||
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 err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function update(
|
||||
tx: Tx,
|
||||
id: string,
|
||||
input: UpdateRepairJobRequest,
|
||||
actor: Actor | null,
|
||||
) {
|
||||
const current = await tx.repairJob.findUnique({ where: { id } });
|
||||
if (!current) throw errors.notFound('Repair');
|
||||
|
||||
const data: Prisma.RepairJobUpdateInput = {};
|
||||
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 = null;
|
||||
}
|
||||
if (input.hostId !== undefined) {
|
||||
data.host = input.hostId ? { connect: { id: input.hostId } } : { disconnect: true };
|
||||
}
|
||||
if (input.assigneeId !== undefined) {
|
||||
data.assignee = input.assigneeId
|
||||
? { connect: { id: input.assigneeId } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
if (input.notes !== undefined) data.notes = input.notes;
|
||||
|
||||
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',
|
||||
newValue: repair.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return repair;
|
||||
}
|
||||
|
||||
export async function remove(tx: Tx, id: string) {
|
||||
try {
|
||||
await tx.repairJob.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
|
||||
throw errors.notFound('Repair');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user