chore: initial Vector 2.0 monorepo
Ground-up TypeScript rewrite of the Vector hardware parts inventory
system. Ships the full roadmap (Phases 0-8) in one initial commit:
- pnpm + Turbo monorepo: apps/{api,web,e2e}, packages/{db,shared,ui,config}
- Express 5 + Prisma 5 + zod validation + JWT w/ refresh-token rotation
- React 19 + Vite + shadcn/ui + TanStack Query/Table + nuqs URL state
- Repair/RMA, tags, bulk ops, saved views, CSV audit export
- Analytics dashboard on Recharts + EOL tracking
- Signed webhook subscriptions (HMAC-SHA256) with in-process emitter
- Vitest unit tests (shared schemas, api services/helpers) + Playwright skeleton
- Gitea Actions CI (lint, typecheck, test+coverage, build) + Renovate
Deferred follow-ups: Postgres cutover (data-migration script ready),
BullMQ worker for webhook delivery, @react-pdf PDF export, CSV import wizard.
This commit is contained in:
@@ -0,0 +1,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
|
||||||
+38
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof BinListQuery>;
|
||||||
|
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 };
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof RoomListQuery>;
|
||||||
|
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 };
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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' } } }),
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<DashboardAnalytics> {
|
||||||
|
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<string, number>();
|
||||||
|
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<string, number>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
await tx.refreshToken.updateMany({
|
||||||
|
where: { userId, revokedAt: null },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<BinWithPath | null> {
|
||||||
|
const bin = await tx.bin.findUnique({ where: { id }, include: binInclude });
|
||||||
|
return bin ? withBinPath(bin) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBin(tx: Tx, input: CreateBinRequest): Promise<BinWithPath> {
|
||||||
|
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<BinWithPath> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PartWithRelations['bin']>;
|
||||||
|
export type PartWithPath = Omit<PartWithRelations, 'tags'> & {
|
||||||
|
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<PartWithPath | null> {
|
||||||
|
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<PartWithPath> {
|
||||||
|
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<PartWithPath> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 } });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<User, 'id' | 'username' | 'email' | 'role' | 'createdAt'>;
|
||||||
|
|
||||||
|
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<PublicUser> {
|
||||||
|
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<PublicUser> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
Vendored
+21
@@ -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 {};
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'] } }],
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
@@ -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.
|
||||||
@@ -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_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vector</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -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 (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<NuqsAdapter>
|
||||||
|
<AuthProvider>
|
||||||
|
<TooltipProvider delayDuration={150}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<AppShell />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/parts" element={<Parts />} />
|
||||||
|
<Route path="/parts/:id" element={<PartDetail />} />
|
||||||
|
<Route path="/locations" element={<Locations />} />
|
||||||
|
<Route path="/manufacturers" element={<Manufacturers />} />
|
||||||
|
<Route path="/repairs" element={<Repairs />} />
|
||||||
|
<Route path="/hosts" element={<Hosts />} />
|
||||||
|
<Route
|
||||||
|
path="/admin/users"
|
||||||
|
element={
|
||||||
|
<RequireAuth role="ADMIN">
|
||||||
|
<Users />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/webhooks"
|
||||||
|
element={
|
||||||
|
<RequireAuth role="ADMIN">
|
||||||
|
<Webhooks />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
<Toaster position="bottom-right" />
|
||||||
|
</TooltipProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</NuqsAdapter>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
{description && <DialogDescription>{description}</DialogDescription>}
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={destructive ? 'destructive' : 'default'}
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!disabled) onSubmit(value.trim());
|
||||||
|
}}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
{description && <DialogDescription>{description}</DialogDescription>}
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={disabled}>
|
||||||
|
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<Skeleton className="h-20 w-72" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 'anonymous' || !user) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
if (role && user.role !== role) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -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<Record<string, PaletteItem[]>>((acc, i) => {
|
||||||
|
(acc[i.group] ||= []).push(i);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<CommandInput placeholder="Search pages, parts, actions…" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No results.</CommandEmpty>
|
||||||
|
{Object.entries(grouped).map(([group, items], idx) => (
|
||||||
|
<CommandGroup key={group} heading={group}>
|
||||||
|
{idx > 0 && <CommandSeparator />}
|
||||||
|
{items.map((item) => (
|
||||||
|
<CommandItem
|
||||||
|
key={item.id}
|
||||||
|
value={item.label}
|
||||||
|
onSelect={() => {
|
||||||
|
navigate(item.to);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4 opacity-70" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience hook: colocate open-state + keyboard trigger for AppShell.
|
||||||
|
export function useCommandPalette() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return { open, setOpen, openPalette: () => setOpen(true) };
|
||||||
|
}
|
||||||
@@ -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<TFilters> {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
sort?: string; // "field:asc" or "field:desc"
|
||||||
|
q?: string;
|
||||||
|
filters: TFilters;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FilterParsers<TFilters> = {
|
||||||
|
[K in keyof TFilters]: ParserBuilder<TFilters[K]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DataTableProps<TData, TFilters extends Record<string, unknown>> {
|
||||||
|
columns: ColumnDef<TData, unknown>[];
|
||||||
|
queryKey: (params: DataTableQueryParams<TFilters>) => readonly unknown[];
|
||||||
|
queryFn: (params: DataTableQueryParams<TFilters>) => Promise<PaginatedResponse<TData>>;
|
||||||
|
/** 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<TFilters>;
|
||||||
|
/** 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: <K extends keyof TFilters>(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<TData, TFilters extends Record<string, unknown>>({
|
||||||
|
columns,
|
||||||
|
queryKey,
|
||||||
|
queryFn,
|
||||||
|
getRowId,
|
||||||
|
filterParsers,
|
||||||
|
defaultPageSize = 20,
|
||||||
|
searchPlaceholder = 'Search…',
|
||||||
|
enableSearch = true,
|
||||||
|
enableSelection = false,
|
||||||
|
bulkActions,
|
||||||
|
toolbar,
|
||||||
|
emptyState,
|
||||||
|
className,
|
||||||
|
}: DataTableProps<TData, TFilters>) {
|
||||||
|
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<TFilters>)) as Record<string, ParserBuilder<unknown>>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
|
||||||
|
const sortingState = useMemo(() => parseSortState(sort), [sort]);
|
||||||
|
const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
|
||||||
|
const next = typeof updater === 'function' ? updater(sortingState) : updater;
|
||||||
|
void setSort(serializeSortState(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const params: DataTableQueryParams<TFilters> = {
|
||||||
|
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<TData, unknown> | null = enableSelection
|
||||||
|
? {
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
aria-label="Select all"
|
||||||
|
checked={
|
||||||
|
table.getIsAllPageRowsSelected()
|
||||||
|
? true
|
||||||
|
: table.getIsSomePageRowsSelected()
|
||||||
|
? 'indeterminate'
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
aria-label="Select row"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(v) => 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 = <K extends keyof TFilters>(name: K, value: TFilters[K] | null) => {
|
||||||
|
void setFilters(
|
||||||
|
(prev) => ({ ...(prev as object), [name]: value } as Partial<typeof filters>),
|
||||||
|
);
|
||||||
|
void setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolbarNode =
|
||||||
|
typeof toolbar === 'function'
|
||||||
|
? toolbar({ filters: filters as TFilters, setFilter })
|
||||||
|
: toolbar;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col gap-3', className)}>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{enableSearch && (
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => {
|
||||||
|
void setQ(e.target.value || null);
|
||||||
|
void setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="h-8 w-64 pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">{toolbarNode}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enableSelection && selectedIds.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border bg-muted/40 px-3 py-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{selectedIds.length} selected
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{bulkActions?.(selectedIds, clearSelection)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearSelection}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const canSort = header.column.getCanSort();
|
||||||
|
const sortDir = header.column.getIsSorted();
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id} style={{ width: header.getSize() || undefined }}>
|
||||||
|
{header.isPlaceholder ? null : canSort ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
className="inline-flex items-center gap-1 text-left text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{sortDir === 'asc' ? (
|
||||||
|
<ChevronUp className="h-3.5 w-3.5" />
|
||||||
|
) : sortDir === 'desc' ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
flexRender(header.column.columnDef.header, header.getContext())
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{query.isPending ? (
|
||||||
|
<SkeletonRows columns={tableColumns.length} pageSize={pageSize} />
|
||||||
|
) : query.isError ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={tableColumns.length}
|
||||||
|
className="py-10 text-center text-sm text-destructive"
|
||||||
|
>
|
||||||
|
{(query.error as Error).message ?? 'Failed to load'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : table.getRowModel().rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={tableColumns.length} className="py-12 text-center">
|
||||||
|
{emptyState ?? (
|
||||||
|
<div className="text-sm text-muted-foreground">No results.</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
table.getRowModel().rows.map((row) => <DataRow key={row.id} row={row} />)
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
{total === 0 ? '0 rows' : `${(page - 1) * pageSize + 1}–${Math.min(page * pageSize, total)} of ${total}`}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={page <= 1 || query.isFetching}
|
||||||
|
onClick={() => void setPage(page - 1)}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="px-2">
|
||||||
|
Page {page} of {pageCount}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={page >= pageCount || query.isFetching}
|
||||||
|
onClick={() => void setPage(page + 1)}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => {
|
||||||
|
void setPageSize(Number(e.target.value));
|
||||||
|
void setPage(1);
|
||||||
|
}}
|
||||||
|
className="ml-2 h-7 rounded-md border border-input bg-transparent px-2 text-xs"
|
||||||
|
>
|
||||||
|
{[10, 20, 50, 100].map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s} / page
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataRow<TData>({ row }: { row: Row<TData> }) {
|
||||||
|
return (
|
||||||
|
<TableRow data-state={row.getIsSelected() ? 'selected' : undefined}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonRows({ columns, pageSize }: { columns: number; pageSize: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: Math.min(pageSize, 8) }).map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
{Array.from({ length: columns }).map((_, j) => (
|
||||||
|
<TableCell key={j}>
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<typeof Schema>;
|
||||||
|
|
||||||
|
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<Values>({
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editing ? 'Edit host' : 'New host'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Hosts are the machines or racks where parts get installed for repair jobs.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input autoFocus {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="location"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Location</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Rack B3, Lab 2" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="notes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Notes</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea rows={3} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{editing ? 'Save changes' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { Sidebar } from './Sidebar.js';
|
||||||
|
import { TopBar } from './TopBar.js';
|
||||||
|
import { CommandPalette, useCommandPalette } from '../command/CommandPalette.js';
|
||||||
|
import { cn } from '@vector/ui';
|
||||||
|
|
||||||
|
export function AppShell() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const palette = useCommandPalette();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed((v) => !v)} />
|
||||||
|
<div className={cn('transition-[padding] duration-200', collapsed ? 'pl-14' : 'pl-64')}>
|
||||||
|
<TopBar onOpenCommand={palette.openPalette} />
|
||||||
|
<main className="p-4 sm:p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<CommandPalette open={palette.open} onOpenChange={palette.setOpen} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
// Humanize a URL slug: "admin/users" -> "Users", "part-detail" -> "Part detail".
|
||||||
|
function humanize(slug: string): string {
|
||||||
|
const cleaned = slug.replace(/-/g, ' ');
|
||||||
|
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Breadcrumbs() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const parts = pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return <div className="text-sm font-medium text-foreground">Dashboard</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Breadcrumb" className="flex items-center gap-1 text-sm">
|
||||||
|
<Link to="/" className="text-muted-foreground hover:text-foreground">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
{parts.map((part, i) => {
|
||||||
|
const to = '/' + parts.slice(0, i + 1).join('/');
|
||||||
|
const isLast = i === parts.length - 1;
|
||||||
|
return (
|
||||||
|
<span key={to} className="flex items-center gap-1">
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||||
|
{isLast ? (
|
||||||
|
<span className="font-medium text-foreground">{humanize(part)}</span>
|
||||||
|
) : (
|
||||||
|
<Link to={to} className="text-muted-foreground hover:text-foreground">
|
||||||
|
{humanize(part)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||||
|
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@vector/ui';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
override state: State = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('ErrorBoundary caught', error, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = () => this.setState({ error: null });
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if (!this.state.error) return this.props.children;
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background p-6">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Something broke</CardTitle>
|
||||||
|
<CardDescription>An unrecoverable error bubbled up.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<pre className="max-h-48 overflow-auto rounded-md bg-muted/40 p-3 text-xs text-muted-foreground">
|
||||||
|
{this.state.error.message}
|
||||||
|
</pre>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||||
|
Reload
|
||||||
|
</Button>
|
||||||
|
<Button onClick={this.handleReset}>Try again</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Boxes,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
LayoutDashboard,
|
||||||
|
type LucideIcon,
|
||||||
|
MapPinned,
|
||||||
|
Package,
|
||||||
|
Server,
|
||||||
|
Users as UsersIcon,
|
||||||
|
Webhook,
|
||||||
|
Wrench,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn, Button, Tooltip, TooltipContent, TooltipTrigger } from '@vector/ui';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext.js';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
to: string;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
adminOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_ITEMS: NavItem[] = [
|
||||||
|
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ to: '/parts', label: 'Parts', icon: Package },
|
||||||
|
{ to: '/locations', label: 'Locations', icon: MapPinned },
|
||||||
|
{ to: '/manufacturers', label: 'Manufacturers', icon: Boxes },
|
||||||
|
{ to: '/repairs', label: 'Repairs', icon: Wrench },
|
||||||
|
{ to: '/hosts', label: 'Hosts', icon: Server },
|
||||||
|
{ to: '/admin/users', label: 'Users', icon: UsersIcon, adminOnly: true },
|
||||||
|
{ to: '/admin/webhooks', label: 'Webhooks', icon: Webhook, adminOnly: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface SidebarProps {
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const items = NAV_ITEMS.filter((i) => !i.adminOnly || user?.role === 'ADMIN');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-y-0 left-0 z-40 flex flex-col border-r border-border bg-card transition-[width] duration-200',
|
||||||
|
collapsed ? 'w-14' : 'w-64',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-13 items-center gap-2 border-b border-border px-3">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-brand text-brand-foreground font-semibold">
|
||||||
|
V
|
||||||
|
</div>
|
||||||
|
{!collapsed && <span className="truncate text-sm font-semibold">Vector</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 space-y-0.5 p-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<NavItemLink key={item.to} item={item} collapsed={collapsed} />
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="border-t border-border p-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn('w-full', !collapsed && 'justify-start gap-2 px-2')}
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronsRight className="h-4 w-4" /> : <ChevronsLeft className="h-4 w-4" />}
|
||||||
|
{!collapsed && <span className="text-xs text-muted-foreground">Collapse</span>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavItemLink({ item, collapsed }: { item: NavItem; collapsed: boolean }) {
|
||||||
|
const content = (
|
||||||
|
<NavLink
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground',
|
||||||
|
collapsed && 'justify-center px-0',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4 shrink-0" />
|
||||||
|
{!collapsed && <span className="truncate">{item.label}</span>}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
if (!collapsed) return content;
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { LogOut, Search } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
Badge,
|
||||||
|
} from '@vector/ui';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext.js';
|
||||||
|
import { Breadcrumbs } from './Breadcrumbs.js';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
// Minimal avatar fallback since we haven't built the Avatar primitive yet. Inline circle.
|
||||||
|
function InitialsAvatar({ name }: { name: string }) {
|
||||||
|
const initials = name
|
||||||
|
.split(/\s+|\./)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((s) => s[0]?.toUpperCase() ?? '')
|
||||||
|
.join('');
|
||||||
|
return (
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-xs font-semibold text-foreground">
|
||||||
|
{initials || '?'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopBarProps {
|
||||||
|
onOpenCommand: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar({ onOpenCommand }: TopBarProps) {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
toast.success('Signed out');
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not sign out');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-30 flex h-13 items-center justify-between gap-3 border-b border-border bg-background/80 px-4 backdrop-blur">
|
||||||
|
<Breadcrumbs />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onOpenCommand}
|
||||||
|
className="h-8 gap-2 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Search</span>
|
||||||
|
<kbd className="ml-2 hidden rounded bg-muted px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground sm:inline">
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="rounded-full">
|
||||||
|
<InitialsAvatar name={user.username} />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-medium text-foreground">{user.username}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{user.email}</span>
|
||||||
|
<Badge variant="outline" className="mt-1 w-fit">
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Sign out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Archive, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
Skeleton,
|
||||||
|
} from '@vector/ui';
|
||||||
|
import { createBin, deleteBin, listBins, updateBin } from '../../lib/api/bins.js';
|
||||||
|
import { ApiRequestError } from '../../lib/api/client.js';
|
||||||
|
import { queryKeys } from '../../lib/queryKeys.js';
|
||||||
|
import { NamePromptDialog } from '../NamePromptDialog.js';
|
||||||
|
import { ConfirmDialog } from '../ConfirmDialog.js';
|
||||||
|
import type { BinWithPath } from '../../lib/api/types.js';
|
||||||
|
|
||||||
|
interface BinGridProps {
|
||||||
|
roomId: string | null;
|
||||||
|
canEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BinGrid({ roomId, canEdit }: BinGridProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [renaming, setRenaming] = useState<BinWithPath | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState<BinWithPath | null>(null);
|
||||||
|
|
||||||
|
const bins = useQuery({
|
||||||
|
queryKey: queryKeys.bins.list({ roomId, pageSize: 100 }),
|
||||||
|
queryFn: () => listBins({ roomId: roomId!, pageSize: 100 }),
|
||||||
|
enabled: Boolean(roomId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => createBin({ name, roomId: roomId! }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Bin created');
|
||||||
|
invalidate();
|
||||||
|
setCreating(false);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof ApiRequestError ? err.body.message : 'Create failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const renameMutation = useMutation({
|
||||||
|
mutationFn: (vars: { id: string; name: string }) => updateBin(vars.id, { name: vars.name }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Bin renamed');
|
||||||
|
invalidate();
|
||||||
|
setRenaming(null);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteBin(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Bin deleted');
|
||||||
|
invalidate();
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||||
|
setDeleting(null);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!roomId) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-8 text-sm text-muted-foreground">
|
||||||
|
Select a room to see its bins.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Bins
|
||||||
|
</h2>
|
||||||
|
{canEdit && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setCreating(true)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New bin
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 pt-0">
|
||||||
|
{bins.isPending ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-20" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : bins.isError ? (
|
||||||
|
<p className="text-sm text-destructive">Failed to load bins.</p>
|
||||||
|
) : bins.data && bins.data.data.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-10 text-muted-foreground">
|
||||||
|
<Archive className="h-6 w-6" />
|
||||||
|
<span className="text-sm">No bins in this room</span>
|
||||||
|
{canEdit && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setCreating(true)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create first bin
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{bins.data!.data.map((b) => (
|
||||||
|
<Card key={b.id} className="group relative">
|
||||||
|
<CardContent className="flex items-start gap-2 p-3">
|
||||||
|
<Archive className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium text-sm">{b.name}</p>
|
||||||
|
<p className="truncate font-mono text-[10px] text-muted-foreground">
|
||||||
|
{b.fullPath}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 opacity-0 transition-opacity group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-36">
|
||||||
|
<DropdownMenuItem onSelect={() => setRenaming(b)}>
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => setDeleting(b)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NamePromptDialog
|
||||||
|
open={creating}
|
||||||
|
onOpenChange={setCreating}
|
||||||
|
title="New bin"
|
||||||
|
label="Bin name"
|
||||||
|
confirmLabel="Create"
|
||||||
|
pending={createMutation.isPending}
|
||||||
|
onSubmit={(name) => createMutation.mutate(name)}
|
||||||
|
/>
|
||||||
|
<NamePromptDialog
|
||||||
|
open={Boolean(renaming)}
|
||||||
|
onOpenChange={(o) => !o && setRenaming(null)}
|
||||||
|
title="Rename bin"
|
||||||
|
label="Bin name"
|
||||||
|
confirmLabel="Rename"
|
||||||
|
initialValue={renaming?.name ?? ''}
|
||||||
|
pending={renameMutation.isPending}
|
||||||
|
onSubmit={(name) => renaming && renameMutation.mutate({ id: renaming.id, name })}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deleting)}
|
||||||
|
onOpenChange={(o) => !o && setDeleting(null)}
|
||||||
|
title="Delete bin?"
|
||||||
|
description={
|
||||||
|
deleting
|
||||||
|
? `Remove ${deleting.name}. Parts in this bin become unassigned.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
destructive
|
||||||
|
pending={deleteMutation.isPending}
|
||||||
|
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { DoorOpen, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
Skeleton,
|
||||||
|
cn,
|
||||||
|
} from '@vector/ui';
|
||||||
|
import { createRoom, deleteRoom, listRooms, updateRoom } from '../../lib/api/rooms.js';
|
||||||
|
import { ApiRequestError } from '../../lib/api/client.js';
|
||||||
|
import { queryKeys } from '../../lib/queryKeys.js';
|
||||||
|
import { NamePromptDialog } from '../NamePromptDialog.js';
|
||||||
|
import { ConfirmDialog } from '../ConfirmDialog.js';
|
||||||
|
import type { Room } from '../../lib/api/types.js';
|
||||||
|
|
||||||
|
interface RoomDrawerProps {
|
||||||
|
siteId: string | null;
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
canEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomDrawer({ siteId, selectedId, onSelect, canEdit }: RoomDrawerProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [renaming, setRenaming] = useState<Room | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState<Room | null>(null);
|
||||||
|
|
||||||
|
const rooms = useQuery({
|
||||||
|
queryKey: queryKeys.rooms.list({ siteId, pageSize: 100 }),
|
||||||
|
queryFn: () => listRooms({ siteId: siteId!, pageSize: 100 }),
|
||||||
|
enabled: Boolean(siteId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.rooms.all });
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => createRoom({ name, siteId: siteId! }),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
toast.success('Room created');
|
||||||
|
invalidate();
|
||||||
|
setCreating(false);
|
||||||
|
onSelect(r.id);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof ApiRequestError ? err.body.message : 'Create failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const renameMutation = useMutation({
|
||||||
|
mutationFn: (vars: { id: string; name: string }) => updateRoom(vars.id, { name: vars.name }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Room renamed');
|
||||||
|
invalidate();
|
||||||
|
setRenaming(null);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteRoom(id),
|
||||||
|
onSuccess: (_, id) => {
|
||||||
|
toast.success('Room deleted');
|
||||||
|
invalidate();
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
|
||||||
|
setDeleting(null);
|
||||||
|
if (selectedId === id) onSelect('');
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!siteId) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
|
||||||
|
Select a site to see its rooms.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Rooms
|
||||||
|
</h2>
|
||||||
|
{canEdit && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCreating(true)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
||||||
|
{rooms.isPending ? (
|
||||||
|
<div className="space-y-2 px-1">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-8 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : rooms.isError ? (
|
||||||
|
<p className="px-3 text-xs text-destructive">Failed to load rooms.</p>
|
||||||
|
) : rooms.data && rooms.data.data.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-1 py-6 text-muted-foreground">
|
||||||
|
<DoorOpen className="h-5 w-5" />
|
||||||
|
<span className="text-xs">No rooms in this site</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{rooms.data!.data.map((r) => {
|
||||||
|
const active = r.id === selectedId;
|
||||||
|
return (
|
||||||
|
<li key={r.id}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center justify-between rounded-md px-2 py-1.5 text-sm',
|
||||||
|
active
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-foreground hover:bg-accent/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(r.id)}
|
||||||
|
className="flex flex-1 items-center gap-2 text-left"
|
||||||
|
>
|
||||||
|
<DoorOpen className="h-4 w-4 opacity-70" />
|
||||||
|
<span className="truncate">{r.name}</span>
|
||||||
|
</button>
|
||||||
|
{canEdit && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-36">
|
||||||
|
<DropdownMenuItem onSelect={() => setRenaming(r)}>
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => setDeleting(r)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NamePromptDialog
|
||||||
|
open={creating}
|
||||||
|
onOpenChange={setCreating}
|
||||||
|
title="New room"
|
||||||
|
label="Room name"
|
||||||
|
confirmLabel="Create"
|
||||||
|
pending={createMutation.isPending}
|
||||||
|
onSubmit={(name) => createMutation.mutate(name)}
|
||||||
|
/>
|
||||||
|
<NamePromptDialog
|
||||||
|
open={Boolean(renaming)}
|
||||||
|
onOpenChange={(o) => !o && setRenaming(null)}
|
||||||
|
title="Rename room"
|
||||||
|
label="Room name"
|
||||||
|
confirmLabel="Rename"
|
||||||
|
initialValue={renaming?.name ?? ''}
|
||||||
|
pending={renameMutation.isPending}
|
||||||
|
onSubmit={(name) => renaming && renameMutation.mutate({ id: renaming.id, name })}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deleting)}
|
||||||
|
onOpenChange={(o) => !o && setDeleting(null)}
|
||||||
|
title="Delete room?"
|
||||||
|
description={
|
||||||
|
deleting
|
||||||
|
? `Remove ${deleting.name}. All bins inside will be deleted too. Parts in those bins become unassigned.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
destructive
|
||||||
|
pending={deleteMutation.isPending}
|
||||||
|
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Building2, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
Skeleton,
|
||||||
|
cn,
|
||||||
|
} from '@vector/ui';
|
||||||
|
import { createSite, deleteSite, listSites, updateSite } from '../../lib/api/sites.js';
|
||||||
|
import { ApiRequestError } from '../../lib/api/client.js';
|
||||||
|
import { queryKeys } from '../../lib/queryKeys.js';
|
||||||
|
import { NamePromptDialog } from '../NamePromptDialog.js';
|
||||||
|
import { ConfirmDialog } from '../ConfirmDialog.js';
|
||||||
|
import type { Site } from '../../lib/api/types.js';
|
||||||
|
|
||||||
|
interface SiteListProps {
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
canEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteList({ selectedId, onSelect, canEdit }: SiteListProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [renaming, setRenaming] = useState<Site | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState<Site | null>(null);
|
||||||
|
|
||||||
|
const sites = useQuery({
|
||||||
|
queryKey: queryKeys.sites.list({ pageSize: 100 }),
|
||||||
|
queryFn: () => listSites({ pageSize: 100 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.sites.all });
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => createSite({ name }),
|
||||||
|
onSuccess: (s) => {
|
||||||
|
toast.success('Site created');
|
||||||
|
invalidate();
|
||||||
|
setCreating(false);
|
||||||
|
onSelect(s.id);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof ApiRequestError ? err.body.message : 'Create failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const renameMutation = useMutation({
|
||||||
|
mutationFn: (vars: { id: string; name: string }) => updateSite(vars.id, { name: vars.name }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Site renamed');
|
||||||
|
invalidate();
|
||||||
|
setRenaming(null);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteSite(id),
|
||||||
|
onSuccess: (_, id) => {
|
||||||
|
toast.success('Site deleted');
|
||||||
|
invalidate();
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.rooms.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
|
||||||
|
setDeleting(null);
|
||||||
|
if (selectedId === id) onSelect('');
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Sites
|
||||||
|
</h2>
|
||||||
|
{canEdit && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCreating(true)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
||||||
|
{sites.isPending ? (
|
||||||
|
<div className="space-y-2 px-1">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-8 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : sites.isError ? (
|
||||||
|
<p className="px-3 text-xs text-destructive">Failed to load sites.</p>
|
||||||
|
) : sites.data && sites.data.data.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-1 py-6 text-muted-foreground">
|
||||||
|
<Building2 className="h-5 w-5" />
|
||||||
|
<span className="text-xs">No sites yet</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{sites.data!.data.map((s) => {
|
||||||
|
const active = s.id === selectedId;
|
||||||
|
return (
|
||||||
|
<li key={s.id}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center justify-between rounded-md px-2 py-1.5 text-sm',
|
||||||
|
active
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-foreground hover:bg-accent/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(s.id)}
|
||||||
|
className="flex flex-1 items-center gap-2 text-left"
|
||||||
|
>
|
||||||
|
<Building2 className="h-4 w-4 opacity-70" />
|
||||||
|
<span className="truncate">{s.name}</span>
|
||||||
|
</button>
|
||||||
|
{canEdit && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-36">
|
||||||
|
<DropdownMenuItem onSelect={() => setRenaming(s)}>
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => setDeleting(s)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NamePromptDialog
|
||||||
|
open={creating}
|
||||||
|
onOpenChange={setCreating}
|
||||||
|
title="New site"
|
||||||
|
label="Site name"
|
||||||
|
confirmLabel="Create"
|
||||||
|
pending={createMutation.isPending}
|
||||||
|
onSubmit={(name) => createMutation.mutate(name)}
|
||||||
|
/>
|
||||||
|
<NamePromptDialog
|
||||||
|
open={Boolean(renaming)}
|
||||||
|
onOpenChange={(o) => !o && setRenaming(null)}
|
||||||
|
title="Rename site"
|
||||||
|
label="Site name"
|
||||||
|
confirmLabel="Rename"
|
||||||
|
initialValue={renaming?.name ?? ''}
|
||||||
|
pending={renameMutation.isPending}
|
||||||
|
onSubmit={(name) => renaming && renameMutation.mutate({ id: renaming.id, name })}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deleting)}
|
||||||
|
onOpenChange={(o) => !o && setDeleting(null)}
|
||||||
|
title="Delete site?"
|
||||||
|
description={
|
||||||
|
deleting
|
||||||
|
? `Remove ${deleting.name}. All rooms and bins inside will be deleted too. Parts in those bins become unassigned.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
destructive
|
||||||
|
pending={deleteMutation.isPending}
|
||||||
|
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user