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