commit 7c0d422228f44192c91637c3ea16128147a8276e Author: josh Date: Thu Apr 16 20:52:32 2026 -0400 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. diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..feb5629 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,94 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +# Cancel superseded runs on the same branch — saves runner minutes on rapid pushes. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + PNPM_VERSION: 10.33.0 + NODE_VERSION: 22 + +jobs: + check: + name: Lint · Typecheck · Test · Build + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Prisma client + run: pnpm -C packages/db exec prisma generate + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + - name: Unit tests (with coverage on api) + run: | + pnpm -C packages/shared test + pnpm -C apps/api test:coverage + + - name: Build + run: pnpm build + + - name: Upload API coverage + uses: actions/upload-artifact@v4 + if: always() + with: + name: api-coverage + path: apps/api/coverage + if-no-files-found: ignore + retention-days: 7 + + e2e: + name: Playwright (smoke) + runs-on: ubuntu-latest + timeout-minutes: 20 + # E2E needs a real DB + running stack. Flip this on by setting the `ENABLE_E2E` + # repo variable to `true` in Gitea after the Postgres-in-CI follow-up lands. + if: ${{ vars.ENABLE_E2E == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm -C packages/db exec prisma generate + - run: pnpm -C apps/e2e exec playwright install --with-deps chromium + - run: pnpm -C apps/e2e test + env: + BASE_URL: ${{ secrets.E2E_BASE_URL }} + TEST_USERNAME: ${{ secrets.E2E_USERNAME }} + TEST_PASSWORD: ${{ secrets.E2E_PASSWORD }} + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: apps/e2e/playwright-report + retention-days: 7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4bf79b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +node_modules/ +.pnpm-store/ +.turbo/ +dist/ +build/ +.next/ +coverage/ + +# env +.env +.env.*.local +.env.local +*.local + +# logs +*.log +npm-debug.log* +pnpm-debug.log* + +# editor +.vscode/ +.idea/ +.DS_Store +Thumbs.db +.claude/ + +# playwright +apps/e2e/test-results/ +apps/e2e/playwright-report/ + +# prisma +apps/api/prisma/dev.db +apps/api/prisma/dev.db-journal +packages/db/prisma/dev.db +packages/db/prisma/dev.db-journal + +# misc +*.tsbuildinfo diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b4677b --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Vector 2.0 + +Hardware parts inventory — monorepo. + +## Layout + +``` +apps/ + web/ # React + Vite client + api/ # Express + Prisma API +packages/ + db/ # Prisma schema + client (placeholder) + shared/ # Shared zod schemas + types (placeholder) + ui/ # Design system + shadcn primitives (placeholder) + config/ # Shared eslint / tsconfig / tailwind (placeholder) +``` + +## Prereqs + +- Node >= 20 +- pnpm (via `npm i -g pnpm` or corepack) +- Docker (for Postgres + Redis in later phases — current apps still use SQLite) + +## Quick start + +```bash +pnpm install +pnpm dev # runs apps/web and apps/api concurrently via Turbo +``` + +The API listens on `http://localhost:3001`; the web app proxies `/api` to it and serves on `http://localhost:5173`. + +## Phase status + +**Phase 0 — Monorepo foundation** ✅ +- pnpm workspaces + Turbo +- `apps/web` and `apps/api` scaffolded +- `packages/*` placeholders +- `docker-compose.yml` for Postgres + Redis + +Later phases: TypeScript + Postgres migration, API refactor, schema extensions, shadcn redesign, feature slices, observability. diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..ceacd62 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,10 @@ +NODE_ENV=development +PORT=3001 +CLIENT_ORIGIN=http://localhost:5173 + +# Provisional local SQLite. Switch to Postgres when Docker is available: +# DATABASE_URL=postgresql://vector:vector@localhost:5432/vector +DATABASE_URL=file:../../packages/db/prisma/dev.db + +# Generate: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" +JWT_SECRET=replace-with-at-least-32-char-random-hex-secret diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..2d2e7e1 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,47 @@ +{ + "name": "@vector/api", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "scripts": { + "dev": "tsx watch --env-file=.env src/index.ts", + "start": "node --env-file=.env dist/index.js", + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "clean": "rimraf dist .turbo coverage" + }, + "dependencies": { + "@vector/db": "workspace:*", + "@vector/shared": "workspace:*", + "bcryptjs": "^3.0.3", + "cookie-parser": "^1.4.7", + "cors": "^2.8.6", + "dotenv": "^17.4.2", + "express": "^5.2.1", + "express-rate-limit": "^8.3.2", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.3", + "pino": "^10.3.1", + "pino-http": "^11.0.0", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/cookie-parser": "^1.4.7", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.7", + "@types/node": "^22.10.2", + "@types/supertest": "^7.2.0", + "@vector/config": "workspace:*", + "@vitest/coverage-v8": "^4.1.4", + "pino-pretty": "^13.1.3", + "supertest": "^7.2.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vitest": "^4.1.4" + } +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..666fdbb --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,95 @@ +import express from 'express'; +import cookieParser from 'cookie-parser'; +import cors from 'cors'; +import helmet from 'helmet'; +import { pinoHttp } from 'pino-http'; +import rateLimit from 'express-rate-limit'; +import { prisma } from '@vector/db'; + +import { env } from './env.js'; +import { logger } from './lib/logger.js'; +import { requestId } from './middleware/request-id.js'; +import { requireCsrf } from './middleware/csrf.js'; +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 siteRoutes from './routes/sites.js'; +import roomRoutes from './routes/rooms.js'; +import binRoutes from './routes/bins.js'; +import partRoutes from './routes/parts.js'; +import tagRoutes from './routes/tags.js'; +import categoryRoutes from './routes/categories.js'; +import hostRoutes from './routes/hosts.js'; +import repairRoutes from './routes/repairs.js'; +import savedViewRoutes from './routes/saved-views.js'; +import analyticsRoutes from './routes/analytics.js'; +import webhookRoutes from './routes/webhooks.js'; +import auditRoutes from './routes/audit.js'; + +export const app = express(); + +app.disable('x-powered-by'); +app.set('trust proxy', 1); + +app.use(helmet({ contentSecurityPolicy: false, crossOriginResourcePolicy: { policy: 'same-site' } })); +app.use( + cors({ + origin: env.CLIENT_ORIGIN, + credentials: true, + }), +); +app.use(express.json({ limit: '1mb' })); +app.use(cookieParser()); +app.use(requestId); +app.use( + pinoHttp({ + logger, + customProps: (req) => ({ requestId: (req as express.Request).requestId }), + customLogLevel: (_req, res, err) => { + if (err || res.statusCode >= 500) return 'error'; + if (res.statusCode >= 400) return 'warn'; + return 'info'; + }, + }), +); + +app.get('/healthz', (_req, res) => { + res.json({ status: 'ok' }); +}); + +app.get('/readyz', async (_req, res) => { + try { + await prisma.$queryRaw`SELECT 1`; + res.json({ status: 'ok', db: 'ok' }); + } catch { + res.status(503).json({ status: 'error', db: 'unreachable' }); + } +}); + +const authLimiter = rateLimit({ + windowMs: 60 * 1000, + limit: env.NODE_ENV === 'production' ? 5 : 50, + standardHeaders: 'draft-7', + legacyHeaders: false, + message: { code: 'RATE_LIMITED', message: 'Too many auth requests. Try again soon.' }, +}); + +app.use('/api/auth', authLimiter, authRoutes); +app.use('/api', requireCsrf); +app.use('/api/users', userRoutes); +app.use('/api/manufacturers', manufacturerRoutes); +app.use('/api/sites', siteRoutes); +app.use('/api/rooms', roomRoutes); +app.use('/api/bins', binRoutes); +app.use('/api/parts', partRoutes); +app.use('/api/tags', tagRoutes); +app.use('/api/categories', categoryRoutes); +app.use('/api/hosts', hostRoutes); +app.use('/api/repairs', repairRoutes); +app.use('/api/saved-views', savedViewRoutes); +app.use('/api/analytics', analyticsRoutes); +app.use('/api/admin/webhooks', webhookRoutes); +app.use('/api/admin/audit', auditRoutes); + +app.use(errorHandler); diff --git a/apps/api/src/controllers/analytics.ts b/apps/api/src/controllers/analytics.ts new file mode 100644 index 0000000..7669049 --- /dev/null +++ b/apps/api/src/controllers/analytics.ts @@ -0,0 +1,12 @@ +import type { NextFunction, Request, Response } from 'express'; +import { prisma } from '@vector/db'; +import * as svc from '../services/analytics.js'; + +export async function dashboard(_req: Request, res: Response, next: NextFunction) { + try { + const data = await prisma.$transaction((tx) => svc.dashboard(tx)); + res.json(data); + } catch (err) { + next(err); + } +} diff --git a/apps/api/src/controllers/audit-export.test.ts b/apps/api/src/controllers/audit-export.test.ts new file mode 100644 index 0000000..ee0da8c --- /dev/null +++ b/apps/api/src/controllers/audit-export.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { csvCell } from './audit-export.js'; + +describe('csvCell', () => { + it('returns empty string for null / undefined', () => { + expect(csvCell(null)).toBe(''); + expect(csvCell(undefined)).toBe(''); + }); + + it('stringifies primitives', () => { + expect(csvCell('hi')).toBe('hi'); + expect(csvCell(42)).toBe('42'); + expect(csvCell(true)).toBe('true'); + }); + + it('formats Date as ISO string', () => { + const d = new Date('2026-01-01T00:00:00.000Z'); + expect(csvCell(d)).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('quotes values containing commas', () => { + expect(csvCell('a,b')).toBe('"a,b"'); + }); + + it('quotes values containing newlines', () => { + expect(csvCell('line1\nline2')).toBe('"line1\nline2"'); + expect(csvCell('line1\r\nline2')).toBe('"line1\r\nline2"'); + }); + + it('escapes embedded double-quotes by doubling them', () => { + expect(csvCell('say "hi"')).toBe('"say ""hi"""'); + }); + + it('leaves plain text untouched', () => { + expect(csvCell('plain-text_123')).toBe('plain-text_123'); + }); +}); diff --git a/apps/api/src/controllers/audit-export.ts b/apps/api/src/controllers/audit-export.ts new file mode 100644 index 0000000..80fdc01 --- /dev/null +++ b/apps/api/src/controllers/audit-export.ts @@ -0,0 +1,100 @@ +import type { NextFunction, Request, Response } from 'express'; +import { z } from 'zod'; +import { prisma, Prisma } from '@vector/db'; +import { PartEventType } from '@vector/shared'; + +const Query = z.object({ + from: z.coerce.date().optional(), + to: z.coerce.date().optional(), + type: PartEventType.optional(), + partId: z.string().uuid().optional(), +}); + +const HEADERS = [ + 'createdAt', + 'eventType', + 'partId', + 'serialNumber', + 'field', + 'oldValue', + 'newValue', + 'actorUsername', +]; + +// CSV-escape: wrap in quotes, double up embedded quotes. Handles commas, newlines, quotes. +export function csvCell(value: unknown): string { + if (value === null || value === undefined) return ''; + const str = value instanceof Date ? value.toISOString() : String(value); + if (/["\n\r,]/.test(str)) return `"${str.replace(/"/g, '""')}"`; + return str; +} + +export async function eventsCsv(req: Request, res: Response, next: NextFunction) { + try { + const parsed = Query.safeParse(req.query); + if (!parsed.success) { + res.status(400).json({ + code: 'VALIDATION_FAILED', + message: 'Invalid export filters', + issues: parsed.error.issues, + }); + return; + } + const { from, to, type, partId } = parsed.data; + + const where: Prisma.PartEventWhereInput = {}; + if (type) where.type = type; + if (partId) where.partId = partId; + if (from || to) { + where.createdAt = {}; + if (from) where.createdAt.gte = from; + if (to) where.createdAt.lte = to; + } + + res.setHeader('content-type', 'text/csv; charset=utf-8'); + res.setHeader( + 'content-disposition', + `attachment; filename="vector-audit-${new Date().toISOString().slice(0, 10)}.csv"`, + ); + res.setHeader('cache-control', 'no-store'); + res.write(HEADERS.join(',') + '\n'); + + // Keyset-paginate by createdAt+id so we never materialize the full table in memory. + const BATCH = 1000; + let cursor: { id: string } | undefined; + for (;;) { + const rows = await prisma.partEvent.findMany({ + where, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + take: BATCH, + ...(cursor ? { skip: 1, cursor } : {}), + include: { + part: { select: { serialNumber: true } }, + user: { select: { username: true } }, + }, + }); + if (rows.length === 0) break; + for (const row of rows) { + res.write( + [ + csvCell(row.createdAt), + csvCell(row.type), + csvCell(row.partId), + csvCell(row.part.serialNumber), + csvCell(row.field), + csvCell(row.oldValue), + csvCell(row.newValue), + csvCell(row.user?.username ?? null), + ].join(',') + '\n', + ); + } + if (rows.length < BATCH) break; + const last = rows[rows.length - 1]; + if (!last) break; + cursor = { id: last.id }; + } + res.end(); + } catch (err) { + next(err); + } +} diff --git a/apps/api/src/controllers/auth.ts b/apps/api/src/controllers/auth.ts new file mode 100644 index 0000000..c068aa5 --- /dev/null +++ b/apps/api/src/controllers/auth.ts @@ -0,0 +1,83 @@ +import type { CookieOptions, NextFunction, Request, Response } from 'express'; +import { prisma } from '@vector/db'; +import type { LoginRequest } from '@vector/shared'; +import { env } from '../env.js'; +import * as authService from '../services/auth.js'; +import { issueCsrfToken } from '../middleware/csrf.js'; +import { errors } from '../lib/http-error.js'; + +const accessCookieOpts: CookieOptions = { + httpOnly: true, + sameSite: 'lax', + secure: env.NODE_ENV === 'production', + path: '/', + maxAge: authService.ACCESS_TOKEN_TTL_MS, +}; + +const refreshCookieOpts: CookieOptions = { + httpOnly: true, + sameSite: 'lax', + secure: env.NODE_ENV === 'production', + path: '/api/auth', + maxAge: authService.REFRESH_TOKEN_TTL_MS, +}; + +function setAuthCookies(res: Response, tokens: authService.AuthTokens) { + res.cookie('token', tokens.accessToken, accessCookieOpts); + res.cookie('refresh', tokens.refreshToken, refreshCookieOpts); + issueCsrfToken(res); +} + +function clearAuthCookies(res: Response) { + res.clearCookie('token', { path: '/' }); + res.clearCookie('refresh', { path: '/api/auth' }); + res.clearCookie('csrf', { path: '/' }); +} + +export async function login(req: Request, res: Response, next: NextFunction) { + try { + const { username, password } = req.validated!.body as LoginRequest; + const { user, tokens } = await prisma.$transaction((tx) => + authService.login(tx, username, password), + ); + setAuthCookies(res, tokens); + res.json(user); + } catch (err) { + next(err); + } +} + +export async function refresh(req: Request, res: Response, next: NextFunction) { + try { + const presented = req.cookies?.refresh; + if (!presented) throw errors.unauthorized('Missing refresh token'); + const { user, tokens } = await prisma.$transaction((tx) => + authService.rotate(tx, presented), + ); + setAuthCookies(res, tokens); + res.json(user); + } catch (err) { + next(err); + } +} + +export async function logout(req: Request, res: Response, next: NextFunction) { + try { + const presented = req.cookies?.refresh; + await prisma.$transaction((tx) => authService.revoke(tx, presented)); + clearAuthCookies(res); + res.json({ message: 'Logged out' }); + } catch (err) { + next(err); + } +} + +export async function me(req: Request, res: Response, next: NextFunction) { + try { + const user = await prisma.user.findUnique({ where: { id: req.user!.id } }); + if (!user) throw errors.unauthorized(); + res.json({ id: user.id, username: user.username, email: user.email, role: user.role }); + } catch (err) { + next(err); + } +} diff --git a/apps/api/src/controllers/bins.ts b/apps/api/src/controllers/bins.ts new file mode 100644 index 0000000..82da27c --- /dev/null +++ b/apps/api/src/controllers/bins.ts @@ -0,0 +1,66 @@ +import type { NextFunction, Request, Response } from 'express'; +import { z } from 'zod'; +import { prisma } from '@vector/db'; +import { + CreateBinRequest, + PaginationQuery, + UpdateBinRequest, +} from '@vector/shared'; +import * as svc from '../services/locations.js'; +import { errors } from '../lib/http-error.js'; + +const BinListQuery = PaginationQuery.extend({ + roomId: z.string().uuid().optional(), + siteId: z.string().uuid().optional(), +}); + +export async function list(req: Request, res: Response, next: NextFunction) { + try { + const q = req.validated!.query as z.infer; + const result = await prisma.$transaction((tx) => svc.listBins(tx, q)); + res.json(result); + } catch (err) { + next(err); + } +} + +export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const bin = await prisma.$transaction((tx) => svc.getBin(tx, req.params.id)); + if (!bin) throw errors.notFound('Bin'); + res.json(bin); + } catch (err) { + next(err); + } +} + +export async function create(req: Request, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as CreateBinRequest; + const bin = await prisma.$transaction((tx) => svc.createBin(tx, input)); + res.status(201).json(bin); + } catch (err) { + next(err); + } +} + +export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as UpdateBinRequest; + const bin = await prisma.$transaction((tx) => svc.updateBin(tx, req.params.id, input)); + res.json(bin); + } catch (err) { + next(err); + } +} + +export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + await prisma.$transaction((tx) => svc.removeBin(tx, req.params.id)); + res.status(204).end(); + } catch (err) { + next(err); + } +} + +export { BinListQuery }; diff --git a/apps/api/src/controllers/categories.ts b/apps/api/src/controllers/categories.ts new file mode 100644 index 0000000..589e786 --- /dev/null +++ b/apps/api/src/controllers/categories.ts @@ -0,0 +1,49 @@ +import type { NextFunction, Request, Response } from 'express'; +import { prisma } from '@vector/db'; +import type { + CategoryListQuery, + CreateCategoryRequest, + UpdateCategoryRequest, +} from '@vector/shared'; +import * as svc from '../services/categories.js'; + +export async function list(req: Request, res: Response, next: NextFunction) { + try { + const q = req.validated!.query as CategoryListQuery; + const result = await prisma.$transaction((tx) => svc.list(tx, q)); + res.json(result); + } catch (err) { + next(err); + } +} + +export async function create(req: Request, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as CreateCategoryRequest; + const category = await prisma.$transaction((tx) => svc.create(tx, input)); + res.status(201).json(category); + } catch (err) { + next(err); + } +} + +export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as UpdateCategoryRequest; + const category = await prisma.$transaction((tx) => + svc.update(tx, req.params.id, input), + ); + res.json(category); + } 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/hosts.ts b/apps/api/src/controllers/hosts.ts new file mode 100644 index 0000000..a0080ed --- /dev/null +++ b/apps/api/src/controllers/hosts.ts @@ -0,0 +1,58 @@ +import type { NextFunction, Request, Response } from 'express'; +import { prisma } from '@vector/db'; +import type { + CreateHostRequest, + HostListQuery, + UpdateHostRequest, +} from '@vector/shared'; +import * as svc from '../services/hosts.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 HostListQuery; + 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 host = await prisma.$transaction((tx) => svc.get(tx, req.params.id)); + if (!host) throw errors.notFound('Host'); + res.json(host); + } catch (err) { + next(err); + } +} + +export async function create(req: Request, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as CreateHostRequest; + const host = await prisma.$transaction((tx) => svc.create(tx, input)); + res.status(201).json(host); + } catch (err) { + next(err); + } +} + +export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as UpdateHostRequest; + const host = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input)); + res.json(host); + } 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/manufacturers.ts b/apps/api/src/controllers/manufacturers.ts new file mode 100644 index 0000000..d777aad --- /dev/null +++ b/apps/api/src/controllers/manufacturers.ts @@ -0,0 +1,50 @@ +import type { NextFunction, Request, Response } from 'express'; +import { prisma } from '@vector/db'; +import type { + CreateManufacturerRequest, + PaginationQuery, + UpdateManufacturerRequest, +} from '@vector/shared'; +import * as svc from '../services/manufacturers.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 PaginationQuery); + const result = await prisma.$transaction((tx) => svc.list(tx, q)); + res.json(result); + } catch (err) { + next(err); + } +} + +export async function create(req: Request, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as CreateManufacturerRequest; + const m = await prisma.$transaction((tx) => svc.create(tx, input)); + res.status(201).json(m); + } catch (err) { + next(err); + } +} + +export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as UpdateManufacturerRequest; + const m = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input)); + res.json(m); + } 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); + } +} + +export { errors }; diff --git a/apps/api/src/controllers/parts.ts b/apps/api/src/controllers/parts.ts new file mode 100644 index 0000000..3f33131 --- /dev/null +++ b/apps/api/src/controllers/parts.ts @@ -0,0 +1,88 @@ +import type { NextFunction, Request, Response } from 'express'; +import { prisma } from '@vector/db'; +import type { + BulkPartsRequest, + CreatePartRequest, + PartEventsQuery, + PartListQuery, + UpdatePartRequest, +} from '@vector/shared'; +import * as svc from '../services/parts.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 PartListQuery; + 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 part = await prisma.$transaction((tx) => svc.get(tx, req.params.id)); + if (!part) throw errors.notFound('Part'); + res.json(part); + } catch (err) { + next(err); + } +} + +export async function create(req: Request, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as CreatePartRequest; + const part = await prisma.$transaction((tx) => + svc.create(tx, input, req.user ?? null), + ); + res.status(201).json(part); + } catch (err) { + next(err); + } +} + +export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as UpdatePartRequest; + const part = await prisma.$transaction((tx) => + svc.update(tx, req.params.id, input, req.user ?? null), + ); + res.json(part); + } 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); + } +} + +export async function bulk(req: Request, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as BulkPartsRequest; + const result = await prisma.$transaction((tx) => + svc.bulkUpdate(tx, input, req.user ?? null), + ); + res.json(result); + } catch (err) { + next(err); + } +} + +export async function getEvents(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const q = req.validated!.query as PartEventsQuery; + const result = await prisma.$transaction((tx) => + svc.listEvents(tx, req.params.id, q), + ); + res.json(result); + } catch (err) { + next(err); + } +} diff --git a/apps/api/src/controllers/repairs.ts b/apps/api/src/controllers/repairs.ts new file mode 100644 index 0000000..854de66 --- /dev/null +++ b/apps/api/src/controllers/repairs.ts @@ -0,0 +1,75 @@ +import type { NextFunction, Request, Response } from 'express'; +import { prisma } from '@vector/db'; +import type { + CreateRepairJobRequest, + RepairJobListQuery, + UpdateRepairJobRequest, +} from '@vector/shared'; +import * as svc from '../services/repairs.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 RepairJobListQuery; + 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 repair = await prisma.$transaction((tx) => svc.get(tx, req.params.id)); + if (!repair) throw errors.notFound('Repair'); + res.json(repair); + } catch (err) { + next(err); + } +} + +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; + const repair = await prisma.$transaction((tx) => + svc.create(tx, input, req.user ?? null), + ); + res.status(201).json(repair); + } catch (err) { + next(err); + } +} + +export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as UpdateRepairJobRequest; + const repair = await prisma.$transaction((tx) => + svc.update(tx, req.params.id, input, req.user ?? null), + ); + res.json(repair); + } 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/rooms.ts b/apps/api/src/controllers/rooms.ts new file mode 100644 index 0000000..8c8e7bd --- /dev/null +++ b/apps/api/src/controllers/rooms.ts @@ -0,0 +1,63 @@ +import type { NextFunction, Request, Response } from 'express'; +import { z } from 'zod'; +import { prisma } from '@vector/db'; +import { + CreateRoomRequest, + PaginationQuery, + UpdateRoomRequest, +} from '@vector/shared'; +import * as svc from '../services/locations.js'; +import { errors } from '../lib/http-error.js'; + +const RoomListQuery = PaginationQuery.extend({ siteId: z.string().uuid().optional() }); + +export async function list(req: Request, res: Response, next: NextFunction) { + try { + const q = req.validated!.query as z.infer; + const result = await prisma.$transaction((tx) => svc.listRooms(tx, q)); + res.json(result); + } catch (err) { + next(err); + } +} + +export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const room = await prisma.$transaction((tx) => svc.getRoom(tx, req.params.id)); + if (!room) throw errors.notFound('Room'); + res.json(room); + } catch (err) { + next(err); + } +} + +export async function create(req: Request, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as CreateRoomRequest; + const room = await prisma.$transaction((tx) => svc.createRoom(tx, input)); + res.status(201).json(room); + } catch (err) { + next(err); + } +} + +export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as UpdateRoomRequest; + const room = await prisma.$transaction((tx) => svc.updateRoom(tx, req.params.id, input)); + res.json(room); + } catch (err) { + next(err); + } +} + +export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + await prisma.$transaction((tx) => svc.removeRoom(tx, req.params.id)); + res.status(204).end(); + } catch (err) { + next(err); + } +} + +export { RoomListQuery }; diff --git a/apps/api/src/controllers/saved-views.ts b/apps/api/src/controllers/saved-views.ts new file mode 100644 index 0000000..3db5124 --- /dev/null +++ b/apps/api/src/controllers/saved-views.ts @@ -0,0 +1,54 @@ +import type { NextFunction, Request, Response } from 'express'; +import { prisma } from '@vector/db'; +import type { + CreateSavedViewRequest, + SavedViewListQuery, + UpdateSavedViewRequest, +} from '@vector/shared'; +import * as svc from '../services/saved-views.js'; +import { errors } from '../lib/http-error.js'; + +export async function list(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) throw errors.unauthorized(); + const q = req.validated!.query as SavedViewListQuery; + const result = await prisma.$transaction((tx) => svc.listMine(tx, req.user!, q)); + res.json(result); + } catch (err) { + next(err); + } +} + +export async function create(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) throw errors.unauthorized(); + const input = req.validated!.body as CreateSavedViewRequest; + const view = await prisma.$transaction((tx) => svc.create(tx, req.user!, input)); + res.status(201).json(view); + } catch (err) { + next(err); + } +} + +export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + if (!req.user) throw errors.unauthorized(); + const input = req.validated!.body as UpdateSavedViewRequest; + const view = await prisma.$transaction((tx) => + svc.update(tx, req.user!, req.params.id, input), + ); + res.json(view); + } catch (err) { + next(err); + } +} + +export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + if (!req.user) throw errors.unauthorized(); + await prisma.$transaction((tx) => svc.remove(tx, req.user!, req.params.id)); + res.status(204).end(); + } catch (err) { + next(err); + } +} diff --git a/apps/api/src/controllers/sites.ts b/apps/api/src/controllers/sites.ts new file mode 100644 index 0000000..a2daff8 --- /dev/null +++ b/apps/api/src/controllers/sites.ts @@ -0,0 +1,58 @@ +import type { NextFunction, Request, Response } from 'express'; +import { prisma } from '@vector/db'; +import type { + CreateSiteRequest, + PaginationQuery, + UpdateSiteRequest, +} from '@vector/shared'; +import * as svc from '../services/locations.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 PaginationQuery; + const result = await prisma.$transaction((tx) => svc.listSites(tx, q)); + res.json(result); + } catch (err) { + next(err); + } +} + +export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const site = await prisma.$transaction((tx) => svc.getSite(tx, req.params.id)); + if (!site) throw errors.notFound('Site'); + res.json(site); + } catch (err) { + next(err); + } +} + +export async function create(req: Request, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as CreateSiteRequest; + const site = await prisma.$transaction((tx) => svc.createSite(tx, input)); + res.status(201).json(site); + } catch (err) { + next(err); + } +} + +export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as UpdateSiteRequest; + const site = await prisma.$transaction((tx) => svc.updateSite(tx, req.params.id, input)); + res.json(site); + } catch (err) { + next(err); + } +} + +export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + await prisma.$transaction((tx) => svc.removeSite(tx, req.params.id)); + res.status(204).end(); + } catch (err) { + next(err); + } +} diff --git a/apps/api/src/controllers/tags.ts b/apps/api/src/controllers/tags.ts new file mode 100644 index 0000000..92bee01 --- /dev/null +++ b/apps/api/src/controllers/tags.ts @@ -0,0 +1,92 @@ +import type { NextFunction, Request, Response } from 'express'; +import { prisma } from '@vector/db'; +import type { + AssignTagsRequest, + CreateTagRequest, + TagListQuery, + UpdateTagRequest, +} from '@vector/shared'; +import * as svc from '../services/tags.js'; + +export async function list(req: Request, res: Response, next: NextFunction) { + try { + const q = req.validated!.query as TagListQuery; + const result = await prisma.$transaction((tx) => svc.list(tx, q)); + res.json(result); + } catch (err) { + next(err); + } +} + +export async function create(req: Request, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as CreateTagRequest; + const tag = await prisma.$transaction((tx) => svc.create(tx, input)); + res.status(201).json(tag); + } catch (err) { + next(err); + } +} + +export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as UpdateTagRequest; + const tag = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input)); + res.json(tag); + } 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); + } +} + +export async function listForPart( + req: Request<{ id: string }>, + res: Response, + next: NextFunction, +) { + try { + const tags = await prisma.$transaction((tx) => svc.listForPart(tx, req.params.id)); + res.json(tags); + } catch (err) { + next(err); + } +} + +export async function assignToPart( + req: Request<{ id: string }>, + res: Response, + next: NextFunction, +) { + try { + const input = req.validated!.body as AssignTagsRequest; + const tags = await prisma.$transaction((tx) => + svc.assignToPart(tx, req.params.id, input, req.user ?? null), + ); + res.json(tags); + } catch (err) { + next(err); + } +} + +export async function unassignFromPart( + req: Request<{ id: string; tagId: string }>, + res: Response, + next: NextFunction, +) { + try { + const tags = await prisma.$transaction((tx) => + svc.unassignFromPart(tx, req.params.id, req.params.tagId, req.user ?? null), + ); + res.json(tags); + } catch (err) { + next(err); + } +} diff --git a/apps/api/src/controllers/users.ts b/apps/api/src/controllers/users.ts new file mode 100644 index 0000000..8c5c429 --- /dev/null +++ b/apps/api/src/controllers/users.ts @@ -0,0 +1,47 @@ +import type { NextFunction, Request, Response } from 'express'; +import { prisma } from '@vector/db'; +import type { + CreateUserRequest, + PaginationQuery, + UpdateUserRequest, +} from '@vector/shared'; +import * as svc from '../services/users.js'; + +export async function listUsers(req: Request, res: Response, next: NextFunction) { + try { + const q = req.validated!.query as PaginationQuery; + const result = await prisma.$transaction((tx) => svc.list(tx, q)); + res.json(result); + } catch (err) { + next(err); + } +} + +export async function createUser(req: Request, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as CreateUserRequest; + const u = await prisma.$transaction((tx) => svc.create(tx, input)); + res.status(201).json(u); + } catch (err) { + next(err); + } +} + +export async function updateUser(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as UpdateUserRequest; + const u = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input)); + res.json(u); + } catch (err) { + next(err); + } +} + +export async function deleteUser(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/webhooks.ts b/apps/api/src/controllers/webhooks.ts new file mode 100644 index 0000000..73d22ef --- /dev/null +++ b/apps/api/src/controllers/webhooks.ts @@ -0,0 +1,56 @@ +import type { NextFunction, Request, Response } from 'express'; +import { prisma } from '@vector/db'; +import type { + CreateWebhookSubscriptionRequest, + UpdateWebhookSubscriptionRequest, + WebhookSubscriptionListQuery, +} from '@vector/shared'; +import * as svc from '../services/webhooks.js'; + +export async function list(req: Request, res: Response, next: NextFunction) { + try { + const q = req.validated!.query as WebhookSubscriptionListQuery; + const result = await prisma.$transaction((tx) => svc.list(tx, q)); + res.json(result); + } catch (err) { + next(err); + } +} + +export async function create(req: Request, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as CreateWebhookSubscriptionRequest; + const sub = await prisma.$transaction((tx) => svc.create(tx, input)); + res.status(201).json(sub); + } catch (err) { + next(err); + } +} + +export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as UpdateWebhookSubscriptionRequest; + const sub = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input)); + res.json(sub); + } 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); + } +} + +export async function rotate(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const sub = await prisma.$transaction((tx) => svc.rotateSecret(tx, req.params.id)); + res.json(sub); + } catch (err) { + next(err); + } +} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts new file mode 100644 index 0000000..f67790d --- /dev/null +++ b/apps/api/src/env.ts @@ -0,0 +1,14 @@ +import 'dotenv/config'; +import { ApiEnv } from '@vector/shared'; + +const parsed = ApiEnv.safeParse(process.env); + +if (!parsed.success) { + console.error('Invalid environment configuration:'); + for (const issue of parsed.error.issues) { + console.error(` ${issue.path.join('.') || '(root)'}: ${issue.message}`); + } + process.exit(1); +} + +export const env = parsed.data; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..9b31ed0 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,7 @@ +import './env.js'; +import { app } from './app.js'; +import { env } from './env.js'; + +app.listen(env.PORT, () => { + console.log(`Vector API listening on port ${env.PORT}`); +}); diff --git a/apps/api/src/lib/http-error.test.ts b/apps/api/src/lib/http-error.test.ts new file mode 100644 index 0000000..9d5be6e --- /dev/null +++ b/apps/api/src/lib/http-error.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { AppError, errors } from './http-error.js'; + +describe('AppError', () => { + it('carries status, code, message, and optional details', () => { + const e = new AppError(418, 'TEAPOT', 'short and stout', { reason: 'tea' }); + expect(e).toBeInstanceOf(Error); + expect(e.status).toBe(418); + expect(e.code).toBe('TEAPOT'); + expect(e.message).toBe('short and stout'); + expect(e.details).toEqual({ reason: 'tea' }); + expect(e.name).toBe('AppError'); + }); +}); + +describe('errors factory', () => { + it('unauthorized defaults and overrides', () => { + const def = errors.unauthorized(); + expect(def.status).toBe(401); + expect(def.code).toBe('UNAUTHORIZED'); + expect(def.message).toBe('Unauthorized'); + expect(errors.unauthorized('custom').message).toBe('custom'); + }); + + it('notFound uses resource name in message', () => { + const e = errors.notFound('Part'); + expect(e.status).toBe(404); + expect(e.message).toBe('Part not found'); + }); + + it('validation wraps details', () => { + const e = errors.validation({ field: 'x' }); + expect(e.status).toBe(400); + expect(e.code).toBe('VALIDATION_ERROR'); + expect(e.details).toEqual({ field: 'x' }); + }); + + it('conflict requires a message', () => { + const e = errors.conflict('serial already exists'); + expect(e.status).toBe(409); + expect(e.code).toBe('CONFLICT'); + expect(e.message).toBe('serial already exists'); + }); + + it('tooManyRequests returns 429', () => { + expect(errors.tooManyRequests().status).toBe(429); + }); +}); diff --git a/apps/api/src/lib/http-error.ts b/apps/api/src/lib/http-error.ts new file mode 100644 index 0000000..c492782 --- /dev/null +++ b/apps/api/src/lib/http-error.ts @@ -0,0 +1,23 @@ +export class AppError extends Error { + status: number; + code: string; + details?: unknown; + + constructor(status: number, code: string, message: string, details?: unknown) { + super(message); + this.name = 'AppError'; + this.status = status; + this.code = code; + this.details = details; + } +} + +export const errors = { + unauthorized: (msg = 'Unauthorized') => new AppError(401, 'UNAUTHORIZED', msg), + forbidden: (msg = 'Forbidden') => new AppError(403, 'FORBIDDEN', msg), + notFound: (resource: string) => new AppError(404, 'NOT_FOUND', `${resource} not found`), + conflict: (msg: string) => new AppError(409, 'CONFLICT', msg), + badRequest: (msg: string, details?: unknown) => new AppError(400, 'BAD_REQUEST', msg, details), + validation: (details: unknown) => new AppError(400, 'VALIDATION_ERROR', 'Validation failed', details), + tooManyRequests: (msg = 'Too many requests') => new AppError(429, 'RATE_LIMITED', msg), +} as const; diff --git a/apps/api/src/lib/logger.ts b/apps/api/src/lib/logger.ts new file mode 100644 index 0000000..9b8044b --- /dev/null +++ b/apps/api/src/lib/logger.ts @@ -0,0 +1,9 @@ +import pino from 'pino'; +import { env } from '../env.js'; + +export const logger = pino({ + level: env.NODE_ENV === 'production' ? 'info' : 'debug', + ...(env.NODE_ENV === 'production' + ? {} + : { transport: { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss.l' } } }), +}); diff --git a/apps/api/src/lib/webhook-emitter.ts b/apps/api/src/lib/webhook-emitter.ts new file mode 100644 index 0000000..6720298 --- /dev/null +++ b/apps/api/src/lib/webhook-emitter.ts @@ -0,0 +1,84 @@ +import type { WebhookEventName } from '@vector/shared'; +import { prisma } from '@vector/db'; +import * as webhooksSvc from '../services/webhooks.js'; +import { logger } from './logger.js'; + +// Recursion guard: deliveries include this header so receivers know the payload +// originated from Vector and can short-circuit echo loops. Worker-side BullMQ +// delivery (planned in the Phase 7 follow-up) will honor the same header plus a +// max-depth check. +export const VECTOR_HOOK_HEADER = 'x-vector-webhook'; + +const DELIVERY_TIMEOUT_MS = 8_000; +const MAX_ATTEMPTS = 3; +const BACKOFF_MS = [0, 2_000, 10_000]; + +interface EmitOptions { + event: WebhookEventName; + payload: Record; +} + +// Fire-and-forget: collects active subscriptions for the event and schedules delivery +// to each. Never throws into caller. This is the interim in-process implementation; +// the plan calls for a BullMQ worker — keep the signature stable so swapping stays +// a one-line change in `emit`. +export async function emit({ event, payload }: EmitOptions): Promise { + const subs = await prisma + .$transaction((tx) => webhooksSvc.listActiveForEvent(tx, event)) + .catch((err) => { + logger.warn({ err, event }, 'webhook emit: subscription lookup failed'); + return []; + }); + if (subs.length === 0) return; + const body = JSON.stringify({ event, data: payload, emittedAt: new Date().toISOString() }); + for (const sub of subs) { + if (!sub.secret) continue; + void deliver(sub.id, sub.url, sub.secret, body, event).catch((err) => { + logger.warn({ err, event, subId: sub.id }, 'webhook delivery crashed'); + }); + } +} + +async function deliver( + subId: string, + url: string, + secret: string, + body: string, + event: WebhookEventName, +): Promise { + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) { + const wait = BACKOFF_MS[attempt] ?? 0; + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); + const timestamp = Math.floor(Date.now() / 1000); + const signature = webhooksSvc.signBody(secret, body, timestamp); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS); + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + [VECTOR_HOOK_HEADER]: 'v1', + 'x-vector-event': event, + 'x-vector-timestamp': String(timestamp), + 'x-vector-signature': signature, + }, + body, + signal: controller.signal, + }); + clearTimeout(timeout); + if (res.ok) { + logger.debug({ subId, event, status: res.status, attempt }, 'webhook delivered'); + return; + } + logger.warn( + { subId, event, status: res.status, attempt }, + 'webhook non-2xx, will retry', + ); + } catch (err) { + clearTimeout(timeout); + logger.warn({ err, subId, event, attempt }, 'webhook delivery error'); + } + } + logger.error({ subId, event }, 'webhook delivery exhausted retries'); +} diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts new file mode 100644 index 0000000..5590ee5 --- /dev/null +++ b/apps/api/src/middleware/auth.ts @@ -0,0 +1,32 @@ +import type { NextFunction, Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; +import type { Role } from '@vector/shared'; +import { env } from '../env.js'; +import { errors } from '../lib/http-error.js'; + +type JwtPayload = { id: string; username: string; role: Role }; + +export function requireAuth(req: Request, _res: Response, next: NextFunction) { + const token = req.cookies?.token; + if (!token) { + next(errors.unauthorized()); + return; + } + try { + const decoded = jwt.verify(token, env.JWT_SECRET) as JwtPayload; + req.user = { id: decoded.id, username: decoded.username, role: decoded.role }; + next(); + } catch { + next(errors.unauthorized('Invalid or expired token')); + } +} + +export function requireRole(role: Role) { + return (req: Request, _res: Response, next: NextFunction) => { + if (!req.user || req.user.role !== role) { + next(errors.forbidden()); + return; + } + next(); + }; +} diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts new file mode 100644 index 0000000..a559fc3 --- /dev/null +++ b/apps/api/src/middleware/csrf.ts @@ -0,0 +1,39 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import type { CookieOptions, NextFunction, Request, Response } from 'express'; +import { env } from '../env.js'; +import { errors } from '../lib/http-error.js'; + +export const CSRF_COOKIE = 'csrf'; +export const CSRF_HEADER = 'x-csrf-token'; +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +export function issueCsrfToken(res: Response): string { + const token = randomBytes(32).toString('hex'); + const opts: CookieOptions = { + httpOnly: false, + sameSite: 'lax', + secure: env.NODE_ENV === 'production', + path: '/', + }; + res.cookie(CSRF_COOKIE, token, opts); + return token; +} + +function tokensMatch(a: string, b: string): boolean { + if (a.length !== b.length) return false; + return timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} + +export function requireCsrf(req: Request, _res: Response, next: NextFunction) { + if (SAFE_METHODS.has(req.method)) { + next(); + return; + } + const cookieToken = req.cookies?.[CSRF_COOKIE]; + const headerToken = req.header(CSRF_HEADER); + if (!cookieToken || !headerToken || !tokensMatch(cookieToken, headerToken)) { + next(errors.forbidden('CSRF token missing or invalid')); + return; + } + next(); +} diff --git a/apps/api/src/middleware/error.ts b/apps/api/src/middleware/error.ts new file mode 100644 index 0000000..d7fd9ae --- /dev/null +++ b/apps/api/src/middleware/error.ts @@ -0,0 +1,57 @@ +import type { NextFunction, Request, Response } from 'express'; +import { ZodError } from 'zod'; +import { Prisma } from '@vector/db'; +import { AppError } from '../lib/http-error.js'; +import { logger } from '../lib/logger.js'; + +interface ErrorEnvelope { + code: string; + message: string; + requestId: string; + details?: unknown; +} + +export function errorHandler( + err: unknown, + req: Request, + res: Response, + _next: NextFunction, +) { + const requestId = req.requestId ?? 'unknown'; + let envelope: ErrorEnvelope; + let status = 500; + + if (err instanceof AppError) { + status = err.status; + envelope = { code: err.code, message: err.message, requestId, details: err.details }; + } else if (err instanceof ZodError) { + status = 400; + envelope = { + code: 'VALIDATION_ERROR', + message: 'Validation failed', + requestId, + details: err.issues, + }; + } else if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === 'P2025') { + status = 404; + envelope = { code: 'NOT_FOUND', message: 'Resource not found', requestId }; + } else if (err.code === 'P2002') { + status = 409; + envelope = { code: 'CONFLICT', message: 'Unique constraint violated', requestId }; + } else if (err.code === 'P2003') { + status = 409; + envelope = { code: 'CONFLICT', message: 'Foreign key constraint violated', requestId }; + } else { + envelope = { code: 'DB_ERROR', message: 'Database error', requestId }; + } + } else { + envelope = { code: 'INTERNAL_ERROR', message: 'Internal server error', requestId }; + } + + const logPayload = { requestId, status, err }; + if (status >= 500) logger.error(logPayload, 'request failed'); + else logger.warn(logPayload, 'request rejected'); + + res.status(status).json(envelope); +} diff --git a/apps/api/src/middleware/request-id.ts b/apps/api/src/middleware/request-id.ts new file mode 100644 index 0000000..3529f12 --- /dev/null +++ b/apps/api/src/middleware/request-id.ts @@ -0,0 +1,10 @@ +import { randomUUID } from 'node:crypto'; +import type { NextFunction, Request, Response } from 'express'; + +export function requestId(req: Request, res: Response, next: NextFunction) { + const incoming = req.header('x-request-id'); + const id = incoming && incoming.length <= 128 ? incoming : randomUUID(); + req.requestId = id; + res.setHeader('X-Request-Id', id); + next(); +} diff --git a/apps/api/src/middleware/validate.ts b/apps/api/src/middleware/validate.ts new file mode 100644 index 0000000..bad2630 --- /dev/null +++ b/apps/api/src/middleware/validate.ts @@ -0,0 +1,17 @@ +import type { NextFunction, Request, Response } from 'express'; +import type { ZodTypeAny } from 'zod'; + +type Target = 'body' | 'query' | 'params'; + +export function validate(target: Target, schema: ZodTypeAny) { + return (req: Request, _res: Response, next: NextFunction) => { + const result = schema.safeParse(req[target]); + if (!result.success) { + next(result.error); + return; + } + req.validated ??= {}; + req.validated[target] = result.data; + next(); + }; +} diff --git a/apps/api/src/routes/analytics.ts b/apps/api/src/routes/analytics.ts new file mode 100644 index 0000000..dbff35e --- /dev/null +++ b/apps/api/src/routes/analytics.ts @@ -0,0 +1,10 @@ +import { Router } from 'express'; +import * as ctrl from '../controllers/analytics.js'; +import { requireAuth } from '../middleware/auth.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/dashboard', ctrl.dashboard); + +export default router; diff --git a/apps/api/src/routes/audit.ts b/apps/api/src/routes/audit.ts new file mode 100644 index 0000000..23bf574 --- /dev/null +++ b/apps/api/src/routes/audit.ts @@ -0,0 +1,10 @@ +import { Router } from 'express'; +import * as ctrl from '../controllers/audit-export.js'; +import { requireAuth, requireRole } from '../middleware/auth.js'; + +const router = Router(); + +router.use(requireAuth, requireRole('ADMIN')); +router.get('/events.csv', ctrl.eventsCsv); + +export default router; diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 0000000..61bb727 --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { LoginRequest } from '@vector/shared'; +import * as ctrl from '../controllers/auth.js'; +import { requireAuth } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; + +const router = Router(); + +router.post('/login', validate('body', LoginRequest), ctrl.login); +router.post('/refresh', ctrl.refresh); +router.post('/logout', ctrl.logout); +router.get('/me', requireAuth, ctrl.me); + +export default router; diff --git a/apps/api/src/routes/bins.ts b/apps/api/src/routes/bins.ts new file mode 100644 index 0000000..01fc887 --- /dev/null +++ b/apps/api/src/routes/bins.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { CreateBinRequest, UpdateBinRequest } from '@vector/shared'; +import * as ctrl from '../controllers/bins.js'; +import { requireAuth, requireRole } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; + +const router = Router(); + +router.get('/', requireAuth, validate('query', ctrl.BinListQuery), ctrl.list); +router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateBinRequest), ctrl.create); +router.get('/:id', requireAuth, ctrl.get); +router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateBinRequest), ctrl.update); +router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove); + +export default router; diff --git a/apps/api/src/routes/categories.ts b/apps/api/src/routes/categories.ts new file mode 100644 index 0000000..07749b5 --- /dev/null +++ b/apps/api/src/routes/categories.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { + CategoryListQuery, + CreateCategoryRequest, + UpdateCategoryRequest, +} from '@vector/shared'; +import * as ctrl from '../controllers/categories.js'; +import { requireAuth, requireRole } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; + +const router = Router(); + +router.get('/', requireAuth, validate('query', CategoryListQuery), ctrl.list); +router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateCategoryRequest), ctrl.create); +router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateCategoryRequest), ctrl.update); +router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove); + +export default router; diff --git a/apps/api/src/routes/hosts.ts b/apps/api/src/routes/hosts.ts new file mode 100644 index 0000000..c24ad05 --- /dev/null +++ b/apps/api/src/routes/hosts.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { + CreateHostRequest, + HostListQuery, + UpdateHostRequest, +} from '@vector/shared'; +import * as ctrl from '../controllers/hosts.js'; +import { requireAuth, requireRole } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; + +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.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateHostRequest), ctrl.update); +router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove); + +export default router; diff --git a/apps/api/src/routes/manufacturers.ts b/apps/api/src/routes/manufacturers.ts new file mode 100644 index 0000000..9ee8190 --- /dev/null +++ b/apps/api/src/routes/manufacturers.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { + CreateManufacturerRequest, + PaginationQuery, + UpdateManufacturerRequest, +} from '@vector/shared'; +import * as ctrl from '../controllers/manufacturers.js'; +import { requireAuth, requireRole } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; + +const router = Router(); + +router.get('/', requireAuth, validate('query', PaginationQuery), ctrl.list); +router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateManufacturerRequest), ctrl.create); +router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateManufacturerRequest), 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 new file mode 100644 index 0000000..d3ae6fa --- /dev/null +++ b/apps/api/src/routes/parts.ts @@ -0,0 +1,32 @@ +import { Router } from 'express'; +import { + AssignTagsRequest, + BulkPartsRequest, + CreatePartRequest, + PartEventsQuery, + PartListQuery, + UpdatePartRequest, +} 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'; + +const router = Router(); + +router.get('/', requireAuth, validate('query', PartListQuery), ctrl.list); +router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreatePartRequest), ctrl.create); +router.post('/bulk', requireAuth, validate('body', BulkPartsRequest), ctrl.bulk); +router.get('/:id', requireAuth, ctrl.get); +router.get('/:id/events', requireAuth, validate('query', PartEventsQuery), ctrl.getEvents); +router.patch('/:id', requireAuth, validate('body', UpdatePartRequest), ctrl.update); +router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove); + +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 new file mode 100644 index 0000000..a08ad4b --- /dev/null +++ b/apps/api/src/routes/repairs.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { + CreateRepairJobRequest, + RepairJobListQuery, + UpdateRepairJobRequest, +} from '@vector/shared'; +import * as ctrl from '../controllers/repairs.js'; +import { requireAuth } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; + +const router = Router(); + +router.get('/', requireAuth, validate('query', RepairJobListQuery), ctrl.list); +router.post('/', requireAuth, validate('body', CreateRepairJobRequest), ctrl.create); +router.get('/:id', requireAuth, ctrl.get); +router.patch('/:id', requireAuth, validate('body', UpdateRepairJobRequest), ctrl.update); +router.delete('/:id', requireAuth, ctrl.remove); + +export default router; diff --git a/apps/api/src/routes/rooms.ts b/apps/api/src/routes/rooms.ts new file mode 100644 index 0000000..51bc4e0 --- /dev/null +++ b/apps/api/src/routes/rooms.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { CreateRoomRequest, UpdateRoomRequest } from '@vector/shared'; +import * as ctrl from '../controllers/rooms.js'; +import { requireAuth, requireRole } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; + +const router = Router(); + +router.get('/', requireAuth, validate('query', ctrl.RoomListQuery), ctrl.list); +router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateRoomRequest), ctrl.create); +router.get('/:id', requireAuth, ctrl.get); +router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateRoomRequest), ctrl.update); +router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove); + +export default router; diff --git a/apps/api/src/routes/saved-views.ts b/apps/api/src/routes/saved-views.ts new file mode 100644 index 0000000..68888a6 --- /dev/null +++ b/apps/api/src/routes/saved-views.ts @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import { + CreateSavedViewRequest, + SavedViewListQuery, + UpdateSavedViewRequest, +} from '@vector/shared'; +import * as ctrl from '../controllers/saved-views.js'; +import { requireAuth } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; + +const router = Router(); + +router.use(requireAuth); + +router.get('/', validate('query', SavedViewListQuery), ctrl.list); +router.post('/', validate('body', CreateSavedViewRequest), ctrl.create); +router.patch('/:id', validate('body', UpdateSavedViewRequest), ctrl.update); +router.delete('/:id', ctrl.remove); + +export default router; diff --git a/apps/api/src/routes/sites.ts b/apps/api/src/routes/sites.ts new file mode 100644 index 0000000..fba1702 --- /dev/null +++ b/apps/api/src/routes/sites.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { CreateSiteRequest, PaginationQuery, UpdateSiteRequest } from '@vector/shared'; +import * as ctrl from '../controllers/sites.js'; +import { requireAuth, requireRole } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; + +const router = Router(); + +router.get('/', requireAuth, validate('query', PaginationQuery), ctrl.list); +router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateSiteRequest), ctrl.create); +router.get('/:id', requireAuth, ctrl.get); +router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateSiteRequest), ctrl.update); +router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove); + +export default router; diff --git a/apps/api/src/routes/tags.ts b/apps/api/src/routes/tags.ts new file mode 100644 index 0000000..21d4d42 --- /dev/null +++ b/apps/api/src/routes/tags.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { + AssignTagsRequest, + CreateTagRequest, + TagListQuery, + UpdateTagRequest, +} from '@vector/shared'; +import * as ctrl from '../controllers/tags.js'; +import { requireAuth, requireRole } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; + +const router = Router(); + +router.get('/', requireAuth, validate('query', TagListQuery), ctrl.list); +router.post('/', requireAuth, validate('body', CreateTagRequest), ctrl.create); +router.patch('/:id', requireAuth, validate('body', UpdateTagRequest), ctrl.update); +router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove); + +export default router; diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts new file mode 100644 index 0000000..ba7c491 --- /dev/null +++ b/apps/api/src/routes/users.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { CreateUserRequest, PaginationQuery, UpdateUserRequest } from '@vector/shared'; +import * as ctrl from '../controllers/users.js'; +import { requireAuth, requireRole } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; + +const router = Router(); + +router.use(requireAuth, requireRole('ADMIN')); + +router.get('/', validate('query', PaginationQuery), ctrl.listUsers); +router.post('/', validate('body', CreateUserRequest), ctrl.createUser); +router.patch('/:id', validate('body', UpdateUserRequest), ctrl.updateUser); +router.delete('/:id', ctrl.deleteUser); + +export default router; diff --git a/apps/api/src/routes/webhooks.ts b/apps/api/src/routes/webhooks.ts new file mode 100644 index 0000000..9c4d475 --- /dev/null +++ b/apps/api/src/routes/webhooks.ts @@ -0,0 +1,21 @@ +import { Router } from 'express'; +import { + CreateWebhookSubscriptionRequest, + UpdateWebhookSubscriptionRequest, + WebhookSubscriptionListQuery, +} from '@vector/shared'; +import * as ctrl from '../controllers/webhooks.js'; +import { requireAuth, requireRole } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; + +const router = Router(); + +router.use(requireAuth, requireRole('ADMIN')); + +router.get('/', validate('query', WebhookSubscriptionListQuery), ctrl.list); +router.post('/', validate('body', CreateWebhookSubscriptionRequest), ctrl.create); +router.patch('/:id', validate('body', UpdateWebhookSubscriptionRequest), ctrl.update); +router.post('/:id/rotate-secret', ctrl.rotate); +router.delete('/:id', ctrl.remove); + +export default router; diff --git a/apps/api/src/services/analytics.test.ts b/apps/api/src/services/analytics.test.ts new file mode 100644 index 0000000..7a89fe8 --- /dev/null +++ b/apps/api/src/services/analytics.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import type { Tx } from './types.js'; +import { dashboard } from './analytics.js'; + +// Minimal in-memory tx double exercising the dashboard() aggregator. +// We only stub the calls dashboard() actually makes; other Prisma methods remain unimplemented. +function makeTx(args: { + partCount: number; + stateRows: { state: string; count: number; totalPrice: number }[]; + parts: { + id: string; + state: string; + binId: string | null; + createdAt: Date; + manufacturerId: string; + }[]; + openRepairs: number; + eolManufacturers: { id: string; name: string; eolDate: Date | null }[]; + bins: { id: string; name: string; room: { name: string; site: { name: string } } }[]; +}): Tx { + const tx = { + part: { + count: async () => args.partCount, + groupBy: async () => + args.stateRows.map((s) => ({ + state: s.state, + _count: { _all: s.count }, + _sum: { price: s.totalPrice }, + })), + findMany: async () => args.parts, + }, + repairJob: { + count: async () => args.openRepairs, + }, + manufacturer: { + findMany: async () => args.eolManufacturers, + }, + bin: { + findMany: async () => args.bins, + }, + }; + return tx as unknown as Tx; +} + +const now = new Date('2026-04-16T00:00:00.000Z'); +const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000); + +describe('analytics.dashboard', () => { + it('aggregates totals, state counts and open repairs', async () => { + const tx = makeTx({ + partCount: 5, + stateRows: [ + { state: 'SPARE', count: 3, totalPrice: 1500 }, + { state: 'DEPLOYED', count: 2, totalPrice: 8000 }, + ], + parts: [], + openRepairs: 4, + eolManufacturers: [], + bins: [], + }); + + const r = await dashboard(tx); + expect(r.totalParts).toBe(5); + expect(r.openRepairs).toBe(4); + expect(r.byState).toEqual([ + { state: 'SPARE', count: 3, totalPrice: 1500 }, + { state: 'DEPLOYED', count: 2, totalPrice: 8000 }, + ]); + }); + + it('buckets parts by age correctly', async () => { + const tx = makeTx({ + 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' }, + ], + openRepairs: 0, + eolManufacturers: [], + bins: [], + }); + + const r = await dashboard(tx); + const byLabel = Object.fromEntries(r.ageBuckets.map((b) => [b.label, b.count])); + expect(byLabel['0–30d']).toBe(1); + expect(byLabel['31–90d']).toBe(1); + expect(byLabel['1–2y']).toBe(1); + expect(byLabel['2y+']).toBe(1); + // totals should match + expect(r.ageBuckets.reduce((s, b) => s + b.count, 0)).toBe(4); + }); + + it('ranks top bins and labels them site/room/bin', async () => { + const tx = makeTx({ + 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' }, + ], + openRepairs: 0, + eolManufacturers: [], + bins: [ + { id: 'b1', name: 'A1', room: { name: 'Lab', site: { name: 'HQ' } } }, + { id: 'b2', name: 'B2', room: { name: 'Lab', site: { name: 'HQ' } } }, + ], + }); + + const r = await dashboard(tx); + expect(r.topBins).toEqual([ + { binId: 'b1', label: 'HQ / Lab / A1', count: 2 }, + { binId: 'b2', label: 'HQ / Lab / B2', count: 1 }, + ]); + }); + + it('flags manufacturers 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' }, + ], + openRepairs: 0, + eolManufacturers: [ + { id: 'm1', name: 'Acme', eolDate: daysAgo(30) }, + { id: 'm2', name: 'Beta', eolDate: daysAgo(10) }, + { id: 'm3', name: 'Gamma', eolDate: daysAgo(5) }, + ], + 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 }); + }); +}); diff --git a/apps/api/src/services/analytics.ts b/apps/api/src/services/analytics.ts new file mode 100644 index 0000000..fa41d39 --- /dev/null +++ b/apps/api/src/services/analytics.ts @@ -0,0 +1,88 @@ +import type { DashboardAnalytics } from '@vector/shared'; +import type { Tx } from './types.js'; + +const DAY = 24 * 60 * 60 * 1000; + +const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [ + { label: '0–30d', maxDays: 30 }, + { label: '31–90d', maxDays: 90 }, + { label: '91–180d', maxDays: 180 }, + { label: '181–365d', maxDays: 365 }, + { label: '1–2y', maxDays: 730 }, + { label: '2y+', maxDays: null }, +]; + +export async function dashboard(tx: Tx): Promise { + const [totalParts, stateRows, parts, openRepairs, manufacturersWithEol] = await Promise.all([ + tx.part.count(), + tx.part.groupBy({ + by: ['state'], + _count: { _all: true }, + _sum: { price: true }, + }), + tx.part.findMany({ + select: { id: true, state: true, binId: true, createdAt: true, manufacturerId: true }, + }), + tx.repairJob.count({ where: { status: { in: ['PENDING', 'IN_PROGRESS'] } } }), + tx.manufacturer.findMany({ + where: { eolDate: { not: null, lte: new Date() } }, + select: { id: true, name: true, eolDate: true }, + }), + ]); + + const byState = stateRows.map((row) => ({ + state: row.state as DashboardAnalytics['byState'][number]['state'], + count: row._count._all, + totalPrice: row._sum.price ?? 0, + })); + + const now = Date.now(); + const buckets = AGE_BUCKETS.map((b) => ({ label: b.label, count: 0 })); + for (const part of parts) { + const ageDays = (now - part.createdAt.getTime()) / DAY; + const idx = AGE_BUCKETS.findIndex((b) => b.maxDays === null || ageDays <= b.maxDays); + const bucket = idx >= 0 ? buckets[idx] : undefined; + if (bucket) bucket.count += 1; + } + + const binCounts = new Map(); + for (const part of parts) { + if (!part.binId) continue; + binCounts.set(part.binId, (binCounts.get(part.binId) ?? 0) + 1); + } + const topBinIds = [...binCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 8) + .map(([id]) => id); + const binRows = topBinIds.length + ? await tx.bin.findMany({ + where: { id: { in: topBinIds } }, + include: { room: { include: { site: true } } }, + }) + : []; + const binLabels = new Map( + binRows.map((b) => [b.id, `${b.room.site.name} / ${b.room.name} / ${b.name}`]), + ); + const topBins = topBinIds.map((id) => ({ + binId: id, + label: binLabels.get(id) ?? 'Unknown', + count: binCounts.get(id) ?? 0, + })); + + const deployedByMfg = new Map(); + for (const part of parts) { + if (part.state !== 'DEPLOYED') continue; + deployedByMfg.set(part.manufacturerId, (deployedByMfg.get(part.manufacturerId) ?? 0) + 1); + } + const deployedPastEol = manufacturersWithEol + .map((m) => ({ + manufacturerId: m.id, + name: m.name, + eolDate: m.eolDate ? m.eolDate.toISOString() : null, + deployedCount: deployedByMfg.get(m.id) ?? 0, + })) + .filter((m) => m.deployedCount > 0) + .sort((a, b) => b.deployedCount - a.deployedCount); + + return { totalParts, byState, ageBuckets: buckets, topBins, deployedPastEol, openRepairs }; +} diff --git a/apps/api/src/services/auth.ts b/apps/api/src/services/auth.ts new file mode 100644 index 0000000..8d0d03b --- /dev/null +++ b/apps/api/src/services/auth.ts @@ -0,0 +1,126 @@ +import { createHash, randomBytes } from 'node:crypto'; +import bcrypt from 'bcryptjs'; +import jwt, { type SignOptions } from 'jsonwebtoken'; +import { Prisma } from '@vector/db'; +import type { Role } from '@vector/shared'; +import { env } from '../env.js'; +import { errors } from '../lib/http-error.js'; +import type { Tx } from './types.js'; + +export const ACCESS_TOKEN_TTL_MS = 15 * 60 * 1000; +export const REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +export interface AuthTokens { + accessToken: string; + refreshToken: string; + refreshExpiresAt: Date; +} + +export interface AuthUser { + id: string; + username: string; + email: string; + role: Role; +} + +function hashRefreshToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +function signAccessToken(user: AuthUser): string { + const opts: SignOptions = { expiresIn: Math.floor(ACCESS_TOKEN_TTL_MS / 1000) }; + return jwt.sign({ id: user.id, username: user.username, role: user.role }, env.JWT_SECRET, opts); +} + +async function issueRefreshToken( + tx: Tx, + userId: string, + replacedBy?: string, +): Promise<{ token: string; expiresAt: Date }> { + const token = randomBytes(48).toString('hex'); + const tokenHash = hashRefreshToken(token); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_MS); + await tx.refreshToken.create({ + data: { userId, tokenHash, expiresAt, replacedBy: replacedBy ?? null }, + }); + return { token, expiresAt }; +} + +export async function login( + tx: Tx, + username: string, + password: string, +): Promise<{ user: AuthUser; tokens: AuthTokens }> { + const user = await tx.user.findUnique({ where: { username } }); + if (!user) throw errors.unauthorized('Invalid credentials'); + const ok = await bcrypt.compare(password, user.passwordHash); + if (!ok) throw errors.unauthorized('Invalid credentials'); + + const publicUser: AuthUser = { + id: user.id, + username: user.username, + email: user.email, + role: user.role as Role, + }; + const accessToken = signAccessToken(publicUser); + const { token: refreshToken, expiresAt } = await issueRefreshToken(tx, user.id); + return { + user: publicUser, + tokens: { accessToken, refreshToken, refreshExpiresAt: expiresAt }, + }; +} + +export async function rotate( + tx: Tx, + presentedToken: string, +): Promise<{ user: AuthUser; tokens: AuthTokens }> { + const tokenHash = hashRefreshToken(presentedToken); + const existing = await tx.refreshToken.findUnique({ + where: { tokenHash }, + include: { user: true }, + }); + if (!existing || existing.revokedAt || existing.expiresAt < new Date()) { + throw errors.unauthorized('Invalid refresh token'); + } + + const { token: newToken, expiresAt } = await issueRefreshToken(tx, existing.userId); + const newRow = await tx.refreshToken.findUnique({ + where: { tokenHash: hashRefreshToken(newToken) }, + }); + await tx.refreshToken.update({ + where: { id: existing.id }, + data: { revokedAt: new Date(), replacedBy: newRow?.id ?? null }, + }); + + const publicUser: AuthUser = { + id: existing.user.id, + username: existing.user.username, + email: existing.user.email, + role: existing.user.role as Role, + }; + return { + user: publicUser, + tokens: { accessToken: signAccessToken(publicUser), refreshToken: newToken, refreshExpiresAt: expiresAt }, + }; +} + +export async function revoke(tx: Tx, presentedToken: string | undefined): Promise { + if (!presentedToken) return; + const tokenHash = hashRefreshToken(presentedToken); + try { + await tx.refreshToken.update({ + where: { tokenHash }, + data: { revokedAt: new Date() }, + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') return; + throw err; + } +} + +export async function revokeAllForUser(tx: Tx, userId: string): Promise { + await tx.refreshToken.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); +} diff --git a/apps/api/src/services/categories.ts b/apps/api/src/services/categories.ts new file mode 100644 index 0000000..4d8699e --- /dev/null +++ b/apps/api/src/services/categories.ts @@ -0,0 +1,60 @@ +import { Prisma } from '@vector/db'; +import type { + CategoryListQuery, + CreateCategoryRequest, + UpdateCategoryRequest, +} from '@vector/shared'; +import { errors } from '../lib/http-error.js'; +import type { Tx } from './types.js'; + +export async function list(tx: Tx, q: CategoryListQuery) { + const { page, pageSize } = q; + const [data, total] = await Promise.all([ + tx.category.findMany({ + orderBy: { name: 'asc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + tx.category.count(), + ]); + return { data, page, pageSize, total }; +} + +export async function create(tx: Tx, input: CreateCategoryRequest) { + try { + return await tx.category.create({ + data: { name: input.name, description: input.description ?? null }, + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw errors.conflict('Category name already exists'); + } + throw err; + } +} + +export async function update(tx: Tx, id: string, input: UpdateCategoryRequest) { + const data: Prisma.CategoryUpdateInput = {}; + if (input.name !== undefined) data.name = input.name; + if (input.description !== undefined) data.description = input.description; + try { + return await tx.category.update({ where: { id }, data }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === 'P2025') throw errors.notFound('Category'); + if (err.code === 'P2002') throw errors.conflict('Category name already exists'); + } + throw err; + } +} + +export async function remove(tx: Tx, id: string) { + try { + await tx.category.delete({ where: { id } }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { + throw errors.notFound('Category'); + } + throw err; + } +} diff --git a/apps/api/src/services/hosts.ts b/apps/api/src/services/hosts.ts new file mode 100644 index 0000000..a020e0c --- /dev/null +++ b/apps/api/src/services/hosts.ts @@ -0,0 +1,78 @@ +import { Prisma } from '@vector/db'; +import type { + CreateHostRequest, + HostListQuery, + UpdateHostRequest, +} from '@vector/shared'; +import { errors } from '../lib/http-error.js'; +import type { Tx } from './types.js'; + +export async function list(tx: Tx, q: HostListQuery) { + const { page, pageSize, q: search } = q; + const where: Prisma.HostWhereInput = search + ? { + OR: [ + { name: { contains: search } }, + { location: { contains: search } }, + ], + } + : {}; + const [data, total] = await Promise.all([ + tx.host.findMany({ + where, + orderBy: { name: 'asc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + tx.host.count({ where }), + ]); + return { data, page, pageSize, total }; +} + +export function get(tx: Tx, id: string) { + return tx.host.findUnique({ where: { id } }); +} + +export async function create(tx: Tx, input: CreateHostRequest) { + try { + return await tx.host.create({ + data: { + name: input.name, + location: input.location ?? null, + notes: input.notes ?? null, + }, + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw errors.conflict('Host name already exists'); + } + throw err; + } +} + +export async function update(tx: Tx, id: string, input: UpdateHostRequest) { + const data: Prisma.HostUpdateInput = {}; + if (input.name !== undefined) data.name = input.name; + if (input.location !== undefined) data.location = input.location; + if (input.notes !== undefined) data.notes = input.notes; + try { + return await tx.host.update({ where: { id }, data }); + } 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'); + } + throw err; + } +} + +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'); + } + throw err; + } +} diff --git a/apps/api/src/services/locations.ts b/apps/api/src/services/locations.ts new file mode 100644 index 0000000..9bc4ff2 --- /dev/null +++ b/apps/api/src/services/locations.ts @@ -0,0 +1,216 @@ +import { Prisma } from '@vector/db'; +import type { + CreateBinRequest, + CreateRoomRequest, + CreateSiteRequest, + PaginationQuery, + UpdateBinRequest, + UpdateRoomRequest, + UpdateSiteRequest, +} from '@vector/shared'; +import { errors } from '../lib/http-error.js'; +import type { Tx } from './types.js'; + +const binInclude = { room: { include: { site: true } } } satisfies Prisma.BinInclude; +type BinWithRelations = Prisma.BinGetPayload<{ include: typeof binInclude }>; +export type BinWithPath = BinWithRelations & { fullPath: string }; + +function binPath(bin: BinWithRelations): string { + return `${bin.room.site.name}.${bin.room.name}.${bin.name}`; +} + +export function withBinPath(bin: BinWithRelations): BinWithPath { + return { ...bin, fullPath: binPath(bin) }; +} + +// ---- sites ---- + +export async function listSites(tx: Tx, q: PaginationQuery) { + const { page, pageSize } = q; + const [data, total] = await Promise.all([ + tx.site.findMany({ + orderBy: { name: 'asc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + tx.site.count(), + ]); + return { data, page, pageSize, total }; +} + +export function getSite(tx: Tx, id: string) { + return tx.site.findUnique({ + where: { id }, + include: { rooms: { include: { bins: true } } }, + }); +} + +export async function createSite(tx: Tx, input: CreateSiteRequest) { + try { + return await tx.site.create({ data: { name: input.name } }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw errors.conflict('Site already exists'); + } + throw err; + } +} + +export async function updateSite(tx: Tx, id: string, input: UpdateSiteRequest) { + try { + return await tx.site.update({ where: { id }, data: { name: input.name } }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === 'P2025') throw errors.notFound('Site'); + if (err.code === 'P2002') throw errors.conflict('Site already exists'); + } + throw err; + } +} + +export async function removeSite(tx: Tx, id: string) { + try { + await tx.site.delete({ where: { id } }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { + throw errors.notFound('Site'); + } + throw err; + } +} + +// ---- rooms ---- + +export async function listRooms(tx: Tx, q: PaginationQuery & { siteId?: string }) { + const { page, pageSize, siteId } = q; + const where = siteId ? { siteId } : {}; + const [data, total] = await Promise.all([ + tx.room.findMany({ + where, + orderBy: { name: 'asc' }, + include: { site: true }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + tx.room.count({ where }), + ]); + return { data, page, pageSize, total }; +} + +export function getRoom(tx: Tx, id: string) { + return tx.room.findUnique({ where: { id }, include: { site: true, bins: true } }); +} + +export async function createRoom(tx: Tx, input: CreateRoomRequest) { + try { + return await tx.room.create({ + data: { name: input.name, siteId: input.siteId }, + include: { site: true }, + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw errors.conflict('Room already exists in this site'); + } + throw err; + } +} + +export async function updateRoom(tx: Tx, id: string, input: UpdateRoomRequest) { + const data: Prisma.RoomUpdateInput = {}; + if (input.name !== undefined) data.name = input.name; + if (input.siteId !== undefined) data.site = { connect: { id: input.siteId } }; + try { + return await tx.room.update({ where: { id }, data, include: { site: true } }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === 'P2025') throw errors.notFound('Room'); + if (err.code === 'P2002') throw errors.conflict('Room already exists in this site'); + } + throw err; + } +} + +export async function removeRoom(tx: Tx, id: string) { + try { + await tx.room.delete({ where: { id } }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { + throw errors.notFound('Room'); + } + throw err; + } +} + +// ---- bins ---- + +export async function listBins( + tx: Tx, + q: PaginationQuery & { roomId?: string; siteId?: string }, +) { + const { page, pageSize, roomId, siteId } = q; + const where: Prisma.BinWhereInput = {}; + if (roomId) where.roomId = roomId; + if (siteId) where.room = { siteId }; + const [rows, total] = await Promise.all([ + tx.bin.findMany({ + where, + orderBy: { name: 'asc' }, + include: binInclude, + skip: (page - 1) * pageSize, + take: pageSize, + }), + tx.bin.count({ where }), + ]); + return { data: rows.map(withBinPath), page, pageSize, total }; +} + +export async function getBin(tx: Tx, id: string): Promise { + const bin = await tx.bin.findUnique({ where: { id }, include: binInclude }); + return bin ? withBinPath(bin) : null; +} + +export async function createBin(tx: Tx, input: CreateBinRequest): Promise { + try { + const bin = await tx.bin.create({ + data: { name: input.name, roomId: input.roomId }, + include: binInclude, + }); + return withBinPath(bin); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw errors.conflict('Bin already exists in this room'); + } + throw err; + } +} + +export async function updateBin( + tx: Tx, + id: string, + input: UpdateBinRequest, +): Promise { + const data: Prisma.BinUpdateInput = {}; + if (input.name !== undefined) data.name = input.name; + if (input.roomId !== undefined) data.room = { connect: { id: input.roomId } }; + try { + const bin = await tx.bin.update({ where: { id }, data, include: binInclude }); + return withBinPath(bin); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === 'P2025') throw errors.notFound('Bin'); + if (err.code === 'P2002') throw errors.conflict('Bin already exists in this room'); + } + throw err; + } +} + +export async function removeBin(tx: Tx, id: string) { + try { + await tx.bin.delete({ where: { id } }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { + throw errors.notFound('Bin'); + } + throw err; + } +} diff --git a/apps/api/src/services/manufacturers.ts b/apps/api/src/services/manufacturers.ts new file mode 100644 index 0000000..b13b060 --- /dev/null +++ b/apps/api/src/services/manufacturers.ts @@ -0,0 +1,66 @@ +import { Prisma } from '@vector/db'; +import type { + CreateManufacturerRequest, + UpdateManufacturerRequest, + PaginationQuery, +} from '@vector/shared'; +import { errors } from '../lib/http-error.js'; +import type { Tx } from './types.js'; + +export async function list(tx: Tx, q: PaginationQuery) { + const { page, pageSize } = q; + const [data, total] = await Promise.all([ + tx.manufacturer.findMany({ + orderBy: { name: 'asc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + tx.manufacturer.count(), + ]); + return { data, page, pageSize, total }; +} + +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, + }, + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw errors.conflict('Manufacturer already exists'); + } + throw err; + } +} + +export async function update(tx: Tx, id: string, input: UpdateManufacturerRequest) { + 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) { + if (err.code === 'P2025') throw errors.notFound('Manufacturer'); + if (err.code === 'P2002') throw errors.conflict('Manufacturer already exists'); + } + throw err; + } +} + +export async function remove(tx: Tx, id: string) { + try { + await tx.manufacturer.delete({ where: { id } }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === 'P2025') throw errors.notFound('Manufacturer'); + if (err.code === 'P2003') { + throw errors.conflict('Cannot delete: manufacturer has parts assigned'); + } + } + throw err; + } +} diff --git a/apps/api/src/services/parts.ts b/apps/api/src/services/parts.ts new file mode 100644 index 0000000..90ee3b3 --- /dev/null +++ b/apps/api/src/services/parts.ts @@ -0,0 +1,328 @@ +import { Prisma } from '@vector/db'; +import type { + CreatePartRequest, + PaginationQuery, + PartListQuery, + UpdatePartRequest, +} from '@vector/shared'; +import { errors } from '../lib/http-error.js'; +import * as tagsSvc from './tags.js'; +import type { Actor, Tx } from './types.js'; + +const partInclude = { + manufacturer: true, + bin: { include: { room: { include: { site: true } } } }, + category: true, + tags: { include: { tag: true } }, +} satisfies Prisma.PartInclude; + +type PartWithRelations = Prisma.PartGetPayload<{ include: typeof partInclude }>; +type BinWithSite = NonNullable; +export type PartWithPath = Omit & { + bin: (BinWithSite & { fullPath?: string }) | null; + tags: { id: string; name: string; color: string | null }[]; +}; + +function binPath(bin: BinWithSite | null | undefined): string | null { + if (!bin) return null; + return `${bin.room.site.name}.${bin.room.name}.${bin.name}`; +} + +function flattenTags(part: PartWithRelations): PartWithPath { + const { tags, ...rest } = part; + const out = rest as PartWithPath; + if (out.bin) out.bin.fullPath = binPath(out.bin) ?? undefined; + out.tags = tags.map((t) => ({ + id: t.tag.id, + name: t.tag.name, + color: t.tag.color, + })); + return out; +} + +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.manufacturerId) where.manufacturerId = q.manufacturerId; + 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 } }, + { 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() } }; + } + return where; +} + +export async function list(tx: Tx, q: PartListQuery) { + const { page, pageSize } = q; + const where = buildWhere(q); + const [rows, total] = await Promise.all([ + tx.part.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: partInclude, + skip: (page - 1) * pageSize, + take: pageSize, + }), + tx.part.count({ where }), + ]); + return { data: rows.map(flattenTags), page, pageSize, total }; +} + +export async function get(tx: Tx, id: string): Promise { + const p = await tx.part.findUnique({ where: { id }, include: partInclude }); + return p ? flattenTags(p) : null; +} + +export async function create( + tx: Tx, + input: CreatePartRequest, + actor: Actor | null, +): Promise { + try { + const p = await tx.part.create({ + data: { + serialNumber: input.serialNumber, + mpn: input.mpn, + manufacturerId: input.manufacturerId, + price: input.price ?? null, + state: input.state ?? 'SPARE', + binId: input.binId ?? null, + categoryId: input.categoryId ?? null, + replacementPartId: input.replacementPartId ?? null, + notes: input.notes ?? null, + }, + include: partInclude, + }); + await tx.partEvent.create({ + data: { + partId: p.id, + userId: actor?.id ?? null, + type: 'CREATED', + newValue: p.serialNumber, + }, + }); + if (input.tagIds && input.tagIds.length > 0) { + await tagsSvc.setPartTags(tx, p.id, input.tagIds, actor); + } + const refreshed = await tx.part.findUnique({ where: { id: p.id }, include: partInclude }); + return flattenTags(refreshed!); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw errors.conflict('Serial number already exists'); + } + throw err; + } +} + +export async function update( + tx: Tx, + id: string, + input: UpdatePartRequest, + actor: Actor | null, +): Promise { + const current = await tx.part.findUnique({ where: { id }, include: partInclude }); + if (!current) throw errors.notFound('Part'); + + 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.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.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; + try { + part = await tx.part.update({ where: { id }, data, include: partInclude }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === 'P2025') throw errors.notFound('Part'); + if (err.code === 'P2002') throw errors.conflict('Serial number already exists'); + } + throw err; + } + + const userId = actor?.id ?? null; + const events: Prisma.PartEventCreateManyInput[] = []; + + if (input.state !== undefined && input.state !== current.state) { + events.push({ + partId: part.id, + userId, + type: 'STATE_CHANGED', + field: 'state', + oldValue: current.state, + newValue: input.state, + }); + } + if (input.binId !== undefined && input.binId !== current.binId) { + events.push({ + partId: part.id, + userId, + type: 'LOCATION_CHANGED', + field: 'bin', + oldValue: binPath(current.bin), + newValue: binPath(part.bin), + }); + } + if (input.mpn !== undefined && input.mpn !== current.mpn) { + events.push({ + partId: part.id, + userId, + type: 'FIELD_UPDATED', + field: 'mpn', + oldValue: current.mpn, + newValue: input.mpn, + }); + } + if (input.serialNumber !== undefined && input.serialNumber !== current.serialNumber) { + events.push({ + partId: part.id, + userId, + type: 'FIELD_UPDATED', + field: 'serialNumber', + oldValue: current.serialNumber, + 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, + userId, + type: 'FIELD_UPDATED', + field: 'category', + oldValue: current.category?.name ?? null, + newValue: part.category?.name ?? null, + }); + } + if (input.price !== undefined && input.price !== current.price) { + events.push({ + partId: part.id, + userId, + type: 'FIELD_UPDATED', + field: 'price', + oldValue: current.price?.toString() ?? null, + newValue: input.price?.toString() ?? null, + }); + } + if (input.notes !== undefined && (input.notes ?? null) !== (current.notes ?? null)) { + events.push({ + partId: part.id, + userId, + type: 'FIELD_UPDATED', + field: 'notes', + oldValue: current.notes ?? null, + newValue: input.notes ?? null, + }); + } + + if (events.length > 0) await tx.partEvent.createMany({ data: events }); + + if (input.tagIds !== undefined) { + await tagsSvc.setPartTags(tx, part.id, input.tagIds, actor); + const refreshed = await tx.part.findUnique({ where: { id: part.id }, include: partInclude }); + return flattenTags(refreshed!); + } + + return flattenTags(part); +} + +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'); + } + throw err; + } +} + +export async function listEvents(tx: Tx, partId: string, q: PaginationQuery) { + const { page, pageSize } = q; + const [data, total] = await Promise.all([ + tx.partEvent.findMany({ + where: { partId }, + orderBy: { createdAt: 'desc' }, + include: { user: { select: { username: true } } }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + tx.partEvent.count({ where: { partId } }), + ]); + return { data, page, pageSize, total }; +} + +// Bulk mutation. Batches all writes inside a single transaction so partial failures roll back. +// Intentionally capped so callers can't accidentally lock the whole parts table. +export interface BulkPartsInput { + ids: string[]; + state?: CreatePartRequest['state']; + binId?: string | null; + addTagIds?: string[]; + removeTagIds?: string[]; +} + +const BULK_LIMIT = 500; + +export async function bulkUpdate(tx: Tx, input: BulkPartsInput, actor: Actor | null) { + if (input.ids.length === 0) throw errors.badRequest('No part ids supplied'); + if (input.ids.length > BULK_LIMIT) { + throw errors.badRequest(`Bulk operations are limited to ${BULK_LIMIT} parts per call`); + } + + const touched: string[] = []; + for (const id of input.ids) { + const patch: UpdatePartRequest = {}; + if (input.state !== undefined) patch.state = input.state; + if (input.binId !== undefined) patch.binId = input.binId; + 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)); + (input.addTagIds ?? []).forEach((t) => next.add(t)); + (input.removeTagIds ?? []).forEach((t) => next.delete(t)); + await tagsSvc.setPartTags(tx, id, [...next], actor); + } + touched.push(id); + } + return { updated: touched.length }; +} diff --git a/apps/api/src/services/repairs.ts b/apps/api/src/services/repairs.ts new file mode 100644 index 0000000..c8ab36f --- /dev/null +++ b/apps/api/src/services/repairs.ts @@ -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; + } +} diff --git a/apps/api/src/services/saved-views.ts b/apps/api/src/services/saved-views.ts new file mode 100644 index 0000000..3337dbd --- /dev/null +++ b/apps/api/src/services/saved-views.ts @@ -0,0 +1,106 @@ +import { Prisma } from '@vector/db'; +import type { + CreateSavedViewRequest, + SavedViewFilter, + SavedViewListQuery, + UpdateSavedViewRequest, +} from '@vector/shared'; +import { errors } from '../lib/http-error.js'; +import type { Actor, Tx } from './types.js'; + +interface SavedViewRow { + id: string; + userId: string; + resource: string; + name: string; + filterJson: string; + createdAt: Date; + updatedAt: Date; +} + +interface SavedView { + id: string; + userId: string; + resource: string; + name: string; + filter: SavedViewFilter; + createdAt: Date; + updatedAt: Date; +} + +// SavedView.filterJson is a JSON-encoded string on SQLite; we parse on read, stringify on write. +function hydrate(row: SavedViewRow): SavedView { + let filter: SavedViewFilter = {}; + try { + filter = JSON.parse(row.filterJson) as SavedViewFilter; + } catch { + // Corrupt row — surface an empty view rather than a 500. Bad rows should be cleaned up. + } + const { filterJson: _ignored, ...rest } = row; + void _ignored; + return { ...rest, filter }; +} + +export async function listMine(tx: Tx, actor: Actor, q: SavedViewListQuery) { + const where: Prisma.SavedViewWhereInput = { userId: actor.id }; + if (q.resource) where.resource = q.resource; + const [rows, total] = await Promise.all([ + tx.savedView.findMany({ + where, + orderBy: { name: 'asc' }, + skip: (q.page - 1) * q.pageSize, + take: q.pageSize, + }), + tx.savedView.count({ where }), + ]); + return { data: rows.map(hydrate), page: q.page, pageSize: q.pageSize, total }; +} + +export async function create(tx: Tx, actor: Actor, input: CreateSavedViewRequest) { + try { + const row = await tx.savedView.create({ + data: { + userId: actor.id, + resource: input.resource, + name: input.name, + filterJson: JSON.stringify(input.filter), + }, + }); + return hydrate(row); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw errors.conflict('A saved view with this name already exists'); + } + throw err; + } +} + +export async function update( + tx: Tx, + actor: Actor, + id: string, + input: UpdateSavedViewRequest, +) { + const existing = await tx.savedView.findUnique({ where: { id } }); + if (!existing || existing.userId !== actor.id) throw errors.notFound('Saved view'); + + const data: Prisma.SavedViewUpdateInput = {}; + if (input.name !== undefined) data.name = input.name; + if (input.filter !== undefined) data.filterJson = JSON.stringify(input.filter); + + try { + const row = await tx.savedView.update({ where: { id }, data }); + return hydrate(row); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw errors.conflict('A saved view with this name already exists'); + } + throw err; + } +} + +export async function remove(tx: Tx, actor: Actor, id: string) { + const existing = await tx.savedView.findUnique({ where: { id } }); + if (!existing || existing.userId !== actor.id) throw errors.notFound('Saved view'); + await tx.savedView.delete({ where: { id } }); +} diff --git a/apps/api/src/services/tags.ts b/apps/api/src/services/tags.ts new file mode 100644 index 0000000..7b3364a --- /dev/null +++ b/apps/api/src/services/tags.ts @@ -0,0 +1,157 @@ +import { Prisma } from '@vector/db'; +import type { + AssignTagsRequest, + CreateTagRequest, + TagListQuery, + UpdateTagRequest, +} from '@vector/shared'; +import { errors } from '../lib/http-error.js'; +import type { Actor, Tx } from './types.js'; + +export async function list(tx: Tx, q: TagListQuery) { + const { page, pageSize, q: search } = q; + const where: Prisma.TagWhereInput = search ? { name: { contains: search } } : {}; + const [data, total] = await Promise.all([ + tx.tag.findMany({ + where, + orderBy: { name: 'asc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + tx.tag.count({ where }), + ]); + return { data, page, pageSize, total }; +} + +export async function create(tx: Tx, input: CreateTagRequest) { + try { + return await tx.tag.create({ + data: { name: input.name, color: input.color ?? null }, + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw errors.conflict('Tag name already exists'); + } + throw err; + } +} + +export async function update(tx: Tx, id: string, input: UpdateTagRequest) { + const data: Prisma.TagUpdateInput = {}; + if (input.name !== undefined) data.name = input.name; + if (input.color !== undefined) data.color = input.color; + try { + return await tx.tag.update({ where: { id }, data }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === 'P2025') throw errors.notFound('Tag'); + if (err.code === 'P2002') throw errors.conflict('Tag name already exists'); + } + throw err; + } +} + +export async function remove(tx: Tx, id: string) { + try { + await tx.tag.delete({ where: { id } }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { + throw errors.notFound('Tag'); + } + throw err; + } +} + +// Replace the full set of tags on a part. Emits TAG_ADDED / TAG_REMOVED events for the diff so +// history stays accurate. Used by assignPart / the part create/update flow. +export async function setPartTags( + tx: Tx, + partId: string, + tagIds: string[], + actor: Actor | null, +) { + const existing = await tx.partTag.findMany({ where: { partId }, select: { tagId: true } }); + const before = new Set(existing.map((r) => r.tagId)); + const after = new Set(tagIds); + + const toAdd = [...after].filter((id) => !before.has(id)); + const toRemove = [...before].filter((id) => !after.has(id)); + + if (toRemove.length > 0) { + await tx.partTag.deleteMany({ + where: { partId, tagId: { in: toRemove } }, + }); + } + if (toAdd.length > 0) { + try { + await tx.partTag.createMany({ + data: toAdd.map((tagId) => ({ partId, tagId })), + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2003') { + throw errors.badRequest('One or more tag ids are invalid'); + } + throw err; + } + } + + if (toAdd.length + toRemove.length > 0) { + const tagNames = await tx.tag.findMany({ + where: { id: { in: [...toAdd, ...toRemove] } }, + select: { id: true, name: true }, + }); + const nameById = new Map(tagNames.map((t) => [t.id, t.name])); + const events: Prisma.PartEventCreateManyInput[] = [ + ...toAdd.map((id) => ({ + partId, + userId: actor?.id ?? null, + type: 'TAG_ADDED' as const, + newValue: nameById.get(id) ?? id, + })), + ...toRemove.map((id) => ({ + partId, + userId: actor?.id ?? null, + type: 'TAG_REMOVED' as const, + oldValue: nameById.get(id) ?? id, + })), + ]; + await tx.partEvent.createMany({ data: events }); + } +} + +export async function listForPart(tx: Tx, partId: string) { + const rows = await tx.partTag.findMany({ + where: { partId }, + include: { tag: true }, + orderBy: { tag: { name: 'asc' } }, + }); + return rows.map((r) => r.tag); +} + +export async function assignToPart( + tx: Tx, + partId: string, + input: AssignTagsRequest, + actor: Actor | null, +) { + const part = await tx.part.findUnique({ where: { id: partId } }); + if (!part) throw errors.notFound('Part'); + const existing = await tx.partTag.findMany({ where: { partId }, select: { tagId: true } }); + const merged = Array.from(new Set([...existing.map((e) => e.tagId), ...input.tagIds])); + await setPartTags(tx, partId, merged, actor); + return listForPart(tx, partId); +} + +export async function unassignFromPart( + tx: Tx, + partId: string, + tagId: string, + actor: Actor | null, +) { + const part = await tx.part.findUnique({ where: { id: partId } }); + if (!part) throw errors.notFound('Part'); + const existing = await tx.partTag.findMany({ where: { partId }, select: { tagId: true } }); + const next = existing.map((e) => e.tagId).filter((id) => id !== tagId); + await setPartTags(tx, partId, next, actor); + return listForPart(tx, partId); +} diff --git a/apps/api/src/services/types.ts b/apps/api/src/services/types.ts new file mode 100644 index 0000000..0c532a2 --- /dev/null +++ b/apps/api/src/services/types.ts @@ -0,0 +1,10 @@ +import type { Prisma } from '@vector/db'; +import type { Role } from '@vector/shared'; + +export type Tx = Prisma.TransactionClient; + +export interface Actor { + id: string; + username: string; + role: Role; +} diff --git a/apps/api/src/services/users.ts b/apps/api/src/services/users.ts new file mode 100644 index 0000000..31a6ad3 --- /dev/null +++ b/apps/api/src/services/users.ts @@ -0,0 +1,77 @@ +import bcrypt from 'bcryptjs'; +import { Prisma, type User } from '@vector/db'; +import type { + CreateUserRequest, + PaginationQuery, + UpdateUserRequest, +} from '@vector/shared'; +import { errors } from '../lib/http-error.js'; +import type { Tx } from './types.js'; + +export type PublicUser = Pick; + +export function toPublic(u: User): PublicUser { + return { id: u.id, username: u.username, email: u.email, role: u.role, createdAt: u.createdAt }; +} + +export async function list(tx: Tx, q: PaginationQuery) { + const { page, pageSize } = q; + const [rows, total] = await Promise.all([ + tx.user.findMany({ + orderBy: { username: 'asc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + tx.user.count(), + ]); + return { data: rows.map(toPublic), page, pageSize, total }; +} + +export async function create(tx: Tx, input: CreateUserRequest): Promise { + try { + const passwordHash = await bcrypt.hash(input.password, 12); + const u = await tx.user.create({ + data: { + username: input.username, + email: input.email, + passwordHash, + role: input.role ?? 'TECHNICIAN', + }, + }); + return toPublic(u); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw errors.conflict('Username or email already exists'); + } + throw err; + } +} + +export async function update(tx: Tx, id: string, input: UpdateUserRequest): Promise { + const data: Prisma.UserUpdateInput = {}; + if (input.username !== undefined) data.username = input.username; + if (input.email !== undefined) data.email = input.email; + if (input.role !== undefined) data.role = input.role; + if (input.password !== undefined) data.passwordHash = await bcrypt.hash(input.password, 12); + try { + const u = await tx.user.update({ where: { id }, data }); + return toPublic(u); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === 'P2025') throw errors.notFound('User'); + if (err.code === 'P2002') throw errors.conflict('Username or email already exists'); + } + throw err; + } +} + +export async function remove(tx: Tx, id: string) { + try { + await tx.user.delete({ where: { id } }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { + throw errors.notFound('User'); + } + throw err; + } +} diff --git a/apps/api/src/services/webhooks.test.ts b/apps/api/src/services/webhooks.test.ts new file mode 100644 index 0000000..d470b54 --- /dev/null +++ b/apps/api/src/services/webhooks.test.ts @@ -0,0 +1,41 @@ +import crypto from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { signBody } from './webhooks.js'; + +describe('signBody', () => { + it('produces a stable hex HMAC-SHA256 of `${timestamp}.${body}`', () => { + const secret = 'test-secret'; + const body = JSON.stringify({ event: 'part.created', data: { id: 'p1' } }); + const ts = 1_700_000_000; + + const signature = signBody(secret, body, ts); + + const expected = crypto + .createHmac('sha256', secret) + .update(`${ts}.${body}`) + .digest('hex'); + expect(signature).toBe(expected); + expect(signature).toHaveLength(64); + expect(signature).toMatch(/^[0-9a-f]+$/); + }); + + it('changes when body changes', () => { + const a = signBody('s', '{"a":1}', 1); + const b = signBody('s', '{"a":2}', 1); + expect(a).not.toBe(b); + }); + + it('changes when timestamp changes (prevents replay)', () => { + const body = '{}'; + const a = signBody('s', body, 1); + const b = signBody('s', body, 2); + expect(a).not.toBe(b); + }); + + it('changes when secret changes', () => { + const body = '{}'; + const a = signBody('secret-a', body, 1); + const b = signBody('secret-b', body, 1); + expect(a).not.toBe(b); + }); +}); diff --git a/apps/api/src/services/webhooks.ts b/apps/api/src/services/webhooks.ts new file mode 100644 index 0000000..c88cc93 --- /dev/null +++ b/apps/api/src/services/webhooks.ts @@ -0,0 +1,138 @@ +import crypto from 'node:crypto'; +import { Prisma } from '@vector/db'; +import type { + CreateWebhookSubscriptionRequest, + UpdateWebhookSubscriptionRequest, + WebhookEventName, + WebhookSubscriptionListQuery, +} from '@vector/shared'; +import { errors } from '../lib/http-error.js'; +import type { Tx } from './types.js'; + +// The DB stores `events` as a JSON string (pending Postgres cutover to String[]). +// Parse on the way out, stringify on the way in. Keep this boundary in the service. +interface StoredSubscription { + id: string; + url: string; + secret: string; + events: string; + active: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface WebhookSubscriptionDto { + id: string; + url: string; + events: WebhookEventName[]; + active: boolean; + createdAt: string; + updatedAt: string; + // `secret` is returned only on create so operators can copy it into their receiver config. + secret?: string; +} + +function toDto(sub: StoredSubscription, includeSecret = false): WebhookSubscriptionDto { + let events: WebhookEventName[] = []; + try { + const parsed = JSON.parse(sub.events); + if (Array.isArray(parsed)) events = parsed as WebhookEventName[]; + } catch { + events = []; + } + return { + id: sub.id, + url: sub.url, + events, + active: sub.active, + createdAt: sub.createdAt.toISOString(), + updatedAt: sub.updatedAt.toISOString(), + ...(includeSecret ? { secret: sub.secret } : {}), + }; +} + +export async function list(tx: Tx, q: WebhookSubscriptionListQuery) { + const { page, pageSize, active } = q; + const where: Prisma.WebhookSubscriptionWhereInput = {}; + if (active !== undefined) where.active = active; + const [rows, total] = await Promise.all([ + tx.webhookSubscription.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + tx.webhookSubscription.count({ where }), + ]); + return { data: rows.map((r) => toDto(r)), page, pageSize, total }; +} + +export async function create(tx: Tx, input: CreateWebhookSubscriptionRequest) { + const secret = crypto.randomBytes(24).toString('base64url'); + const row = await tx.webhookSubscription.create({ + data: { + url: input.url, + secret, + events: JSON.stringify(input.events), + active: input.active ?? true, + }, + }); + return toDto(row, true); +} + +export async function update(tx: Tx, id: string, input: UpdateWebhookSubscriptionRequest) { + const data: Prisma.WebhookSubscriptionUpdateInput = {}; + if (input.url !== undefined) data.url = input.url; + if (input.events !== undefined) data.events = JSON.stringify(input.events); + if (input.active !== undefined) data.active = input.active; + try { + const row = await tx.webhookSubscription.update({ where: { id }, data }); + return toDto(row); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { + throw errors.notFound('WebhookSubscription'); + } + throw err; + } +} + +export async function remove(tx: Tx, id: string) { + try { + await tx.webhookSubscription.delete({ where: { id } }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { + throw errors.notFound('WebhookSubscription'); + } + throw err; + } +} + +export async function rotateSecret(tx: Tx, id: string) { + const secret = crypto.randomBytes(24).toString('base64url'); + try { + const row = await tx.webhookSubscription.update({ + where: { id }, + data: { secret }, + }); + return toDto(row, true); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { + throw errors.notFound('WebhookSubscription'); + } + throw err; + } +} + +export async function listActiveForEvent(tx: Tx, event: WebhookEventName) { + const rows = await tx.webhookSubscription.findMany({ where: { active: true } }); + return rows + .map((r) => toDto(r, true)) + .filter((s) => s.events.includes(event)); +} + +export function signBody(secret: string, body: string, timestamp: number): string { + return crypto + .createHmac('sha256', secret) + .update(`${timestamp}.${body}`) + .digest('hex'); +} diff --git a/apps/api/src/types/express.d.ts b/apps/api/src/types/express.d.ts new file mode 100644 index 0000000..f216440 --- /dev/null +++ b/apps/api/src/types/express.d.ts @@ -0,0 +1,21 @@ +import type { Role } from '@vector/shared'; + +declare global { + namespace Express { + interface Request { + user?: { + id: string; + username: string; + role: Role; + }; + validated?: { + body?: unknown; + query?: unknown; + params?: unknown; + }; + requestId?: string; + } + } +} + +export {}; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..dd506a1 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@vector/config/tsconfig/node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": false, + "declarationMap": false + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..36a5b71 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + include: ['src/services/**', 'src/lib/**'], + exclude: ['**/*.test.ts', '**/types.ts'], + thresholds: { + lines: 60, + functions: 60, + branches: 60, + statements: 60, + }, + }, + }, +}); diff --git a/apps/e2e/package.json b/apps/e2e/package.json new file mode 100644 index 0000000..071e7b6 --- /dev/null +++ b/apps/e2e/package.json @@ -0,0 +1,18 @@ +{ + "name": "@vector/e2e", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "install-browsers": "playwright install --with-deps chromium", + "typecheck": "tsc -p tsconfig.json --noEmit", + "clean": "rimraf test-results playwright-report .turbo" + }, + "devDependencies": { + "@playwright/test": "^1.50.0", + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } +} diff --git a/apps/e2e/playwright.config.ts b/apps/e2e/playwright.config.ts new file mode 100644 index 0000000..ba11c28 --- /dev/null +++ b/apps/e2e/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, devices } from '@playwright/test'; + +// Pointed at a local dev server by default. Override in CI with BASE_URL. +// Start the web + api stack yourself (`pnpm dev` from repo root) before running `pnpm -C apps/e2e test`. +const BASE_URL = process.env.BASE_URL ?? 'http://localhost:5173'; + +export default defineConfig({ + testDir: './tests', + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + reporter: [['list'], ['html', { open: 'never' }]], + use: { + baseURL: BASE_URL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/apps/e2e/tests/admin-audit.spec.ts b/apps/e2e/tests/admin-audit.spec.ts new file mode 100644 index 0000000..39b5b91 --- /dev/null +++ b/apps/e2e/tests/admin-audit.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from '@playwright/test'; + +const username = process.env.TEST_USERNAME; +const password = process.env.TEST_PASSWORD; + +test.beforeEach(async ({ page }) => { + test.skip(!username || !password, 'TEST_USERNAME/TEST_PASSWORD not set'); + await page.goto('/login'); + await page.getByLabel(/username/i).fill(username!); + await page.getByLabel(/password/i).fill(password!); + await page.getByRole('button', { name: /sign in|log in/i }).click(); + await expect(page).toHaveURL(/\/(?!login)/, { timeout: 10_000 }); +}); + +test('admin can fetch the audit CSV export', async ({ page, request }) => { + const csv = await request.get('/api/admin/audit/events.csv'); + expect(csv.status()).toBe(200); + expect(csv.headers()['content-type']).toContain('text/csv'); + const body = await csv.text(); + expect(body.split('\n')[0]).toContain('createdAt'); + expect(body.split('\n')[0]).toContain('eventType'); +}); diff --git a/apps/e2e/tests/bulk.spec.ts b/apps/e2e/tests/bulk.spec.ts new file mode 100644 index 0000000..0cd04d6 --- /dev/null +++ b/apps/e2e/tests/bulk.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test'; + +const username = process.env.TEST_USERNAME; +const password = process.env.TEST_PASSWORD; + +test.beforeEach(async ({ page }) => { + test.skip(!username || !password, 'TEST_USERNAME/TEST_PASSWORD not set'); + await page.goto('/login'); + await page.getByLabel(/username/i).fill(username!); + await page.getByLabel(/password/i).fill(password!); + await page.getByRole('button', { name: /sign in|log in/i }).click(); + await expect(page).toHaveURL(/\/(?!login)/, { timeout: 10_000 }); +}); + +test('bulk-edit dialog opens from the parts table', async ({ page }) => { + await page.goto('/parts'); + + // Select the first visible checkbox (row selector). If there are no rows, skip. + const rowCheckbox = page.locator('tr [role=checkbox]').first(); + if ((await rowCheckbox.count()) === 0) test.skip(true, 'no parts to bulk-edit'); + await rowCheckbox.check(); + + await page.getByRole('button', { name: /bulk|change state|edit selected/i }).first().click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByText(/bulk/i).first()).toBeVisible(); +}); diff --git a/apps/e2e/tests/login.spec.ts b/apps/e2e/tests/login.spec.ts new file mode 100644 index 0000000..5d28d55 --- /dev/null +++ b/apps/e2e/tests/login.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test'; + +// Requires a dev user. Set TEST_USERNAME / TEST_PASSWORD in the environment; otherwise the test skips. +const username = process.env.TEST_USERNAME; +const password = process.env.TEST_PASSWORD; + +test.describe('login', () => { + test.skip(!username || !password, 'TEST_USERNAME/TEST_PASSWORD not set'); + + test('logs in and lands on the dashboard', async ({ page }) => { + await page.goto('/login'); + await page.getByLabel(/username/i).fill(username!); + await page.getByLabel(/password/i).fill(password!); + await page.getByRole('button', { name: /sign in|log in/i }).click(); + + await expect(page).toHaveURL(/\/(?!login)/, { timeout: 10_000 }); + await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible(); + }); + + test('shows an error on bad credentials', async ({ page }) => { + await page.goto('/login'); + await page.getByLabel(/username/i).fill('does-not-exist'); + await page.getByLabel(/password/i).fill('wrong-password'); + await page.getByRole('button', { name: /sign in|log in/i }).click(); + + await expect(page.getByText(/invalid|incorrect|unauthor/i)).toBeVisible(); + }); +}); diff --git a/apps/e2e/tests/parts.spec.ts b/apps/e2e/tests/parts.spec.ts new file mode 100644 index 0000000..1f29daa --- /dev/null +++ b/apps/e2e/tests/parts.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; + +const username = process.env.TEST_USERNAME; +const password = process.env.TEST_PASSWORD; + +// Lightweight fixture: every test starts logged in as an admin. +test.beforeEach(async ({ page }) => { + test.skip(!username || !password, 'TEST_USERNAME/TEST_PASSWORD not set'); + await page.goto('/login'); + await page.getByLabel(/username/i).fill(username!); + await page.getByLabel(/password/i).fill(password!); + await page.getByRole('button', { name: /sign in|log in/i }).click(); + await expect(page).toHaveURL(/\/(?!login)/, { timeout: 10_000 }); +}); + +test.describe('parts', () => { + test('lists parts with working search', async ({ page }) => { + await page.goto('/parts'); + await expect(page.getByRole('heading', { name: /parts/i })).toBeVisible(); + const search = page.getByPlaceholder(/search/i); + if (await search.count()) { + await search.fill('nonexistent-serial-xxxxxxx'); + // Search debounces — give it a beat. + await page.waitForTimeout(600); + await expect(page.getByText(/no parts|no results|empty/i).first()).toBeVisible(); + } + }); + + test('opens the create part dialog', async ({ page }) => { + await page.goto('/parts'); + const newBtn = page.getByRole('button', { name: /new part|add part|\+ part/i }).first(); + if (await newBtn.count()) { + await newBtn.click(); + await expect(page.getByRole('dialog')).toBeVisible(); + } + }); +}); diff --git a/apps/e2e/tests/repair.spec.ts b/apps/e2e/tests/repair.spec.ts new file mode 100644 index 0000000..59c5498 --- /dev/null +++ b/apps/e2e/tests/repair.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; + +const username = process.env.TEST_USERNAME; +const password = process.env.TEST_PASSWORD; + +test.beforeEach(async ({ page }) => { + test.skip(!username || !password, 'TEST_USERNAME/TEST_PASSWORD not set'); + await page.goto('/login'); + await page.getByLabel(/username/i).fill(username!); + await page.getByLabel(/password/i).fill(password!); + await page.getByRole('button', { name: /sign in|log in/i }).click(); + await expect(page).toHaveURL(/\/(?!login)/, { timeout: 10_000 }); +}); + +test('repairs page renders and filters by status', async ({ page }) => { + await page.goto('/repairs'); + await expect(page.getByRole('heading', { name: /repairs/i })).toBeVisible(); + + const statusFilter = page.getByRole('combobox').first(); + if (await statusFilter.count()) { + await statusFilter.click(); + await page.getByRole('option', { name: /in progress|pending/i }).first().click(); + } +}); diff --git a/apps/e2e/tsconfig.json b/apps/e2e/tsconfig.json new file mode 100644 index 0000000..5337f3b --- /dev/null +++ b/apps/e2e/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["playwright.config.ts", "tests/**/*.ts"] +} diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..a36934d --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 0000000..4fa125d --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..d1b0a89 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Vector + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..3acd9bd --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,46 @@ +{ + "name": "@vector/web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview", + "typecheck": "tsc -p tsconfig.json --noEmit", + "clean": "rimraf dist .turbo" + }, + "dependencies": { + "@hookform/resolvers": "^3.10.0", + "@tanstack/react-query": "^5.99.0", + "@tanstack/react-table": "^8.20.6", + "@vector/shared": "workspace:*", + "@vector/ui": "workspace:*", + "axios": "^1.15.0", + "lucide-react": "^0.469.0", + "nuqs": "^2.2.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-hook-form": "^7.54.2", + "react-router-dom": "^7.14.1", + "recharts": "^3.8.1", + "sonner": "^1.7.1", + "zod": "^3.24.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@tailwindcss/vite": "^4.2.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vector/config": "workspace:*", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "tailwindcss": "^4.2.2", + "typescript": "^5.7.2", + "vite": "^8.0.4" + } +} diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/icons.svg b/apps/web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/apps/web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..74202a1 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,86 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; +import { NuqsAdapter } from 'nuqs/adapters/react-router/v7'; +import { TooltipProvider, Toaster } from '@vector/ui'; +import { AuthProvider } from './contexts/AuthContext.js'; +import { RequireAuth } from './components/auth/RequireAuth.js'; +import { AppShell } from './components/layout/AppShell.js'; +import { ErrorBoundary } from './components/layout/ErrorBoundary.js'; +import Login from './pages/Login.js'; +import Dashboard from './pages/Dashboard.js'; +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 Repairs from './pages/Repairs.js'; +import Hosts from './pages/Hosts.js'; +import Users from './pages/admin/Users.js'; +import Webhooks from './pages/admin/Webhooks.js'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: (failureCount, error) => { + // Don't retry auth failures — the refresh interceptor handles those once already. + const status = (error as { status?: number })?.status; + if (status === 401 || status === 403) return false; + return failureCount < 2; + }, + staleTime: 10_000, + refetchOnWindowFocus: false, + }, + }, +}); + +export default function App() { + return ( + + + + + + + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + } /> + + + + + + + + + ); +} diff --git a/apps/web/src/assets/hero.png b/apps/web/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/apps/web/src/assets/hero.png differ diff --git a/apps/web/src/assets/react.svg b/apps/web/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/apps/web/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/assets/vite.svg b/apps/web/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/apps/web/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/apps/web/src/components/ConfirmDialog.tsx b/apps/web/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..3411adf --- /dev/null +++ b/apps/web/src/components/ConfirmDialog.tsx @@ -0,0 +1,56 @@ +import { Loader2 } from 'lucide-react'; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@vector/ui'; + +interface ConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: string; + confirmLabel?: string; + destructive?: boolean; + pending?: boolean; + onConfirm: () => void; +} + +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = 'Confirm', + destructive, + pending, + onConfirm, +}: ConfirmDialogProps) { + return ( + + + + {title} + {description && {description}} + + + + + + + + ); +} diff --git a/apps/web/src/components/NamePromptDialog.tsx b/apps/web/src/components/NamePromptDialog.tsx new file mode 100644 index 0000000..00a4858 --- /dev/null +++ b/apps/web/src/components/NamePromptDialog.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from '@vector/ui'; + +interface NamePromptDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: string; + label?: string; + initialValue?: string; + confirmLabel?: string; + pending?: boolean; + onSubmit: (value: string) => void; +} + +export function NamePromptDialog({ + open, + onOpenChange, + title, + description, + label = 'Name', + initialValue = '', + confirmLabel = 'Save', + pending, + onSubmit, +}: NamePromptDialogProps) { + const [value, setValue] = useState(initialValue); + useEffect(() => { + if (open) setValue(initialValue); + }, [open, initialValue]); + + const disabled = pending || value.trim().length === 0; + + return ( + + +
{ + e.preventDefault(); + if (!disabled) onSubmit(value.trim()); + }} + className="space-y-3" + > + + {title} + {description && {description}} + +
+ + setValue(e.target.value)} + disabled={pending} + /> +
+ + + + +
+
+
+ ); +} diff --git a/apps/web/src/components/auth/RequireAuth.tsx b/apps/web/src/components/auth/RequireAuth.tsx new file mode 100644 index 0000000..4abd610 --- /dev/null +++ b/apps/web/src/components/auth/RequireAuth.tsx @@ -0,0 +1,30 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import type { ReactNode } from 'react'; +import { Skeleton } from '@vector/ui'; +import { useAuth } from '../../contexts/AuthContext.js'; +import type { Role } from '@vector/shared'; + +interface RequireAuthProps { + children: ReactNode; + role?: Role; +} + +export function RequireAuth({ children, role }: RequireAuthProps) { + const { user, status } = useAuth(); + const location = useLocation(); + + if (status === 'loading') { + return ( +
+ +
+ ); + } + if (status === 'anonymous' || !user) { + return ; + } + if (role && user.role !== role) { + return ; + } + return <>{children}; +} diff --git a/apps/web/src/components/command/CommandPalette.tsx b/apps/web/src/components/command/CommandPalette.tsx new file mode 100644 index 0000000..59660be --- /dev/null +++ b/apps/web/src/components/command/CommandPalette.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Boxes, LayoutDashboard, MapPinned, Package, Wrench } from 'lucide-react'; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@vector/ui'; + +interface PaletteItem { + id: string; + label: string; + to: string; + icon: React.ComponentType<{ className?: string }>; + group: 'Navigate' | 'Actions'; +} + +// Stub: nav-only entries for Phase 4. Phase 5+ will merge in recent-parts + saved-views. +const ITEMS: PaletteItem[] = [ + { id: 'nav-dashboard', label: 'Dashboard', to: '/', icon: LayoutDashboard, group: 'Navigate' }, + { id: 'nav-parts', label: 'Parts', to: '/parts', icon: Package, group: 'Navigate' }, + { id: 'nav-locations', label: 'Locations', to: '/locations', icon: MapPinned, group: 'Navigate' }, + { id: 'nav-manufacturers', label: 'Manufacturers', to: '/manufacturers', icon: Boxes, group: 'Navigate' }, + { id: 'nav-repairs', label: 'Repairs', to: '/repairs', icon: Wrench, group: 'Navigate' }, +]; + +export interface CommandPaletteProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { + const navigate = useNavigate(); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + onOpenChange(!open); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onOpenChange]); + + const grouped = ITEMS.reduce>((acc, i) => { + (acc[i.group] ||= []).push(i); + return acc; + }, {}); + + return ( + + + + No results. + {Object.entries(grouped).map(([group, items], idx) => ( + + {idx > 0 && } + {items.map((item) => ( + { + navigate(item.to); + onOpenChange(false); + }} + > + + {item.label} + + ))} + + ))} + + + ); +} + +// Convenience hook: colocate open-state + keyboard trigger for AppShell. +export function useCommandPalette() { + const [open, setOpen] = useState(false); + return { open, setOpen, openPalette: () => setOpen(true) }; +} diff --git a/apps/web/src/components/data-table/DataTable.tsx b/apps/web/src/components/data-table/DataTable.tsx new file mode 100644 index 0000000..e843448 --- /dev/null +++ b/apps/web/src/components/data-table/DataTable.tsx @@ -0,0 +1,380 @@ +import { useMemo, useState, type ReactNode } from 'react'; +import { + flexRender, + getCoreRowModel, + useReactTable, + type ColumnDef, + type OnChangeFn, + type Row, + type RowSelectionState, + type SortingState, +} from '@tanstack/react-table'; +import { useQuery, keepPreviousData } from '@tanstack/react-query'; +import { + parseAsInteger, + parseAsString, + useQueryState, + useQueryStates, + type ParserBuilder, +} from 'nuqs'; +import { ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, ChevronUp, Search } from 'lucide-react'; +import type { PaginatedResponse } from '@vector/shared'; +import { + Button, + Checkbox, + Input, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + cn, +} from '@vector/ui'; + +// Common shape the DataTable forwards to the consumer's queryFn. +export interface DataTableQueryParams { + page: number; + pageSize: number; + sort?: string; // "field:asc" or "field:desc" + q?: string; + filters: TFilters; +} + +export type FilterParsers = { + [K in keyof TFilters]: ParserBuilder; +}; + +export interface DataTableProps> { + columns: ColumnDef[]; + queryKey: (params: DataTableQueryParams) => readonly unknown[]; + queryFn: (params: DataTableQueryParams) => Promise>; + /** How to get a stable string id per row (usually `(r) => r.id`). */ + getRowId: (row: TData) => string; + /** nuqs parsers for resource-specific filters; each becomes a URL query param. */ + filterParsers?: FilterParsers; + /** Default page size; the user may still adjust. */ + defaultPageSize?: number; + searchPlaceholder?: string; + enableSearch?: boolean; + enableSelection?: boolean; + /** Rendered when at least one row is selected. Receives the selected row IDs. */ + bulkActions?: (selectedIds: string[], clear: () => void) => ReactNode; + /** + * Rendered above the table on the right. Either a node, or a render prop that receives the + * current filter state + a setter so consumers can drive URL-synced filters. + */ + toolbar?: + | ReactNode + | ((helpers: { + filters: TFilters; + setFilter: (name: K, value: TFilters[K] | null) => void; + }) => ReactNode); + emptyState?: ReactNode; + className?: string; +} + +// Parse "field:dir" into a TanStack sorting state. Returns [] when empty. +function parseSortState(sort: string | null): SortingState { + if (!sort) return []; + const [id, dir = 'asc'] = sort.split(':'); + if (!id) return []; + return [{ id, desc: dir === 'desc' }]; +} +function serializeSortState(state: SortingState): string | null { + if (state.length === 0) return null; + const [first] = state; + return `${first.id}:${first.desc ? 'desc' : 'asc'}`; +} + +export function DataTable>({ + columns, + queryKey, + queryFn, + getRowId, + filterParsers, + defaultPageSize = 20, + searchPlaceholder = 'Search…', + enableSearch = true, + enableSelection = false, + bulkActions, + toolbar, + emptyState, + className, +}: DataTableProps) { + const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1)); + const [pageSize, setPageSize] = useQueryState( + 'pageSize', + parseAsInteger.withDefault(defaultPageSize), + ); + const [q, setQ] = useQueryState( + 'q', + parseAsString.withDefault('').withOptions({ throttleMs: 300 }), + ); + const [sort, setSort] = useQueryState('sort', parseAsString); + + // Resource-specific filters. When filterParsers is omitted, we still render but with no URL state. + const [filters, setFilters] = useQueryStates( + (filterParsers ?? ({} as FilterParsers)) as Record>, + ); + + const [rowSelection, setRowSelection] = useState({}); + + const sortingState = useMemo(() => parseSortState(sort), [sort]); + const handleSortingChange: OnChangeFn = (updater) => { + const next = typeof updater === 'function' ? updater(sortingState) : updater; + void setSort(serializeSortState(next)); + }; + + const params: DataTableQueryParams = { + page, + pageSize, + sort: sort ?? undefined, + q: q || undefined, + filters: filters as TFilters, + }; + + const query = useQuery({ + queryKey: queryKey(params), + queryFn: () => queryFn(params), + placeholderData: keepPreviousData, + staleTime: 10_000, + }); + + const rows = query.data?.data ?? []; + const total = query.data?.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + + const selectionColumn: ColumnDef | null = enableSelection + ? { + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!v)} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!v)} + /> + ), + enableSorting: false, + size: 32, + } + : null; + + const tableColumns = useMemo( + () => (selectionColumn ? [selectionColumn, ...columns] : columns), + [selectionColumn, columns], + ); + + const table = useReactTable({ + data: rows, + columns: tableColumns, + getRowId, + state: { sorting: sortingState, rowSelection }, + onSortingChange: handleSortingChange, + onRowSelectionChange: setRowSelection, + manualSorting: true, + manualPagination: true, + enableRowSelection: enableSelection, + getCoreRowModel: getCoreRowModel(), + pageCount, + }); + + const selectedIds = Object.keys(rowSelection); + + const clearSelection = () => setRowSelection({}); + const setFilter = (name: K, value: TFilters[K] | null) => { + void setFilters( + (prev) => ({ ...(prev as object), [name]: value } as Partial), + ); + void setPage(1); + }; + + const toolbarNode = + typeof toolbar === 'function' + ? toolbar({ filters: filters as TFilters, setFilter }) + : toolbar; + + return ( +
+
+
+ {enableSearch && ( +
+ + { + void setQ(e.target.value || null); + void setPage(1); + }} + placeholder={searchPlaceholder} + className="h-8 w-64 pl-8" + /> +
+ )} +
+
{toolbarNode}
+
+ + {enableSelection && selectedIds.length > 0 && ( +
+ + {selectedIds.length} selected + +
+ {bulkActions?.(selectedIds, clearSelection)} + +
+
+ )} + +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const canSort = header.column.getCanSort(); + const sortDir = header.column.getIsSorted(); + return ( + + {header.isPlaceholder ? null : canSort ? ( + + ) : ( + flexRender(header.column.columnDef.header, header.getContext()) + )} + + ); + })} + + ))} + + + {query.isPending ? ( + + ) : query.isError ? ( + + + {(query.error as Error).message ?? 'Failed to load'} + + + ) : table.getRowModel().rows.length === 0 ? ( + + + {emptyState ?? ( +
No results.
+ )} +
+
+ ) : ( + table.getRowModel().rows.map((row) => ) + )} +
+
+
+ +
+
+ {total === 0 ? '0 rows' : `${(page - 1) * pageSize + 1}–${Math.min(page * pageSize, total)} of ${total}`} +
+
+ + + Page {page} of {pageCount} + + + +
+
+
+ ); +} + +function DataRow({ row }: { row: Row }) { + return ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + ); +} + +function SkeletonRows({ columns, pageSize }: { columns: number; pageSize: number }) { + return ( + <> + {Array.from({ length: Math.min(pageSize, 8) }).map((_, i) => ( + + {Array.from({ length: columns }).map((_, j) => ( + + + + ))} + + ))} + + ); +} diff --git a/apps/web/src/components/hosts/HostFormDialog.tsx b/apps/web/src/components/hosts/HostFormDialog.tsx new file mode 100644 index 0000000..107e45b --- /dev/null +++ b/apps/web/src/components/hosts/HostFormDialog.tsx @@ -0,0 +1,149 @@ +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { z } from 'zod'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Textarea, +} from '@vector/ui'; +import { createHost, updateHost } from '../../lib/api/hosts.js'; +import { ApiRequestError } from '../../lib/api/client.js'; +import { queryKeys } from '../../lib/queryKeys.js'; +import type { Host } from '../../lib/api/types.js'; + +const Schema = z.object({ + name: z.string().min(1, 'Required').max(128), + location: z.string().max(256).optional(), + notes: z.string().max(4096).optional(), +}); +type Values = z.infer; + +interface HostFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + host?: Host | null; +} + +export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps) { + const editing = Boolean(host); + const queryClient = useQueryClient(); + + const form = useForm({ + resolver: zodResolver(Schema), + defaultValues: { name: '', location: '', notes: '' }, + }); + + useEffect(() => { + if (!open) return; + form.reset({ + name: host?.name ?? '', + location: host?.location ?? '', + notes: host?.notes ?? '', + }); + }, [open, host, form]); + + const mutation = useMutation({ + mutationFn: async (values: Values) => { + const payload = { + 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'); + queryClient.invalidateQueries({ queryKey: queryKeys.hosts.all }); + onOpenChange(false); + }, + onError: (err) => + toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'), + }); + + return ( + + + + {editing ? 'Edit host' : 'New host'} + + Hosts are the machines or racks where parts get installed for repair jobs. + + + +
+ mutation.mutate(v))} className="space-y-3"> + ( + + Name + + + + + + )} + /> + ( + + Location + + + + + + )} + /> + ( + + Notes + +