chore: initial Vector 2.0 monorepo
CI / Lint · Typecheck · Test · Build (push) Failing after 5m41s
CI / Playwright (smoke) (push) Has been skipped

Ground-up TypeScript rewrite of the Vector hardware parts inventory
system. Ships the full roadmap (Phases 0-8) in one initial commit:

- pnpm + Turbo monorepo: apps/{api,web,e2e}, packages/{db,shared,ui,config}
- Express 5 + Prisma 5 + zod validation + JWT w/ refresh-token rotation
- React 19 + Vite + shadcn/ui + TanStack Query/Table + nuqs URL state
- Repair/RMA, tags, bulk ops, saved views, CSV audit export
- Analytics dashboard on Recharts + EOL tracking
- Signed webhook subscriptions (HMAC-SHA256) with in-process emitter
- Vitest unit tests (shared schemas, api services/helpers) + Playwright skeleton
- Gitea Actions CI (lint, typecheck, test+coverage, build) + Renovate

Deferred follow-ups: Postgres cutover (data-migration script ready),
BullMQ worker for webhook delivery, @react-pdf PDF export, CSV import wizard.
This commit is contained in:
2026-04-16 20:52:32 -04:00
commit 7c0d422228
216 changed files with 19393 additions and 0 deletions
@@ -0,0 +1,121 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'TECHNICIAN',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Manufacturer" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Site" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Room" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"siteId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Room_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Bin" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"roomId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Bin_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Part" (
"id" TEXT NOT NULL PRIMARY KEY,
"serialNumber" TEXT NOT NULL,
"mpn" TEXT NOT NULL,
"manufacturerId" TEXT NOT NULL,
"price" REAL,
"state" TEXT NOT NULL DEFAULT 'SPARE',
"binId" TEXT,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Part_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Part_binId_fkey" FOREIGN KEY ("binId") REFERENCES "Bin" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "PartEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"partId" TEXT NOT NULL,
"userId" TEXT,
"type" TEXT NOT NULL,
"field" TEXT,
"oldValue" TEXT,
"newValue" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PartEvent_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "PartEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Manufacturer_name_key" ON "Manufacturer"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Site_name_key" ON "Site"("name");
-- CreateIndex
CREATE INDEX "Room_siteId_idx" ON "Room"("siteId");
-- CreateIndex
CREATE UNIQUE INDEX "Room_siteId_name_key" ON "Room"("siteId", "name");
-- CreateIndex
CREATE INDEX "Bin_roomId_idx" ON "Bin"("roomId");
-- CreateIndex
CREATE UNIQUE INDEX "Bin_roomId_name_key" ON "Bin"("roomId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "Part_serialNumber_key" ON "Part"("serialNumber");
-- CreateIndex
CREATE INDEX "Part_state_idx" ON "Part"("state");
-- CreateIndex
CREATE INDEX "Part_binId_idx" ON "Part"("binId");
-- CreateIndex
CREATE INDEX "Part_manufacturerId_idx" ON "Part"("manufacturerId");
-- CreateIndex
CREATE INDEX "Part_mpn_idx" ON "Part"("mpn");
-- CreateIndex
CREATE INDEX "PartEvent_partId_createdAt_idx" ON "PartEvent"("partId", "createdAt" DESC);
-- CreateIndex
CREATE INDEX "PartEvent_userId_idx" ON "PartEvent"("userId");
@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"revokedAt" DATETIME,
"replacedBy" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
-- CreateIndex
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
-- CreateIndex
CREATE INDEX "RefreshToken_expiresAt_idx" ON "RefreshToken"("expiresAt");
@@ -0,0 +1,173 @@
-- AlterTable
ALTER TABLE "Manufacturer" ADD COLUMN "eolDate" DATETIME;
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"color" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "PartTag" (
"partId" TEXT NOT NULL,
"tagId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("partId", "tagId"),
CONSTRAINT "PartTag_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "PartTag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Host" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"location" TEXT,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "RepairJob" (
"id" TEXT NOT NULL PRIMARY KEY,
"partId" TEXT NOT NULL,
"hostId" TEXT,
"assigneeId" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"openedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"closedAt" DATETIME,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "RepairJob_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "RepairJob_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "RepairJob_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WebhookSubscription" (
"id" TEXT NOT NULL PRIMARY KEY,
"url" TEXT NOT NULL,
"secret" TEXT NOT NULL,
"events" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "SavedView" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"resource" TEXT NOT NULL,
"name" TEXT NOT NULL,
"filterJson" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SavedView_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "CsvImportJob" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT,
"resource" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"filename" TEXT NOT NULL,
"stagedRows" INTEGER NOT NULL DEFAULT 0,
"errors" TEXT,
"startedAt" DATETIME,
"finishedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "CsvImportJob_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Part" (
"id" TEXT NOT NULL PRIMARY KEY,
"serialNumber" TEXT NOT NULL,
"mpn" TEXT NOT NULL,
"manufacturerId" TEXT NOT NULL,
"price" REAL,
"state" TEXT NOT NULL DEFAULT 'SPARE',
"binId" TEXT,
"categoryId" TEXT,
"replacementPartId" TEXT,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Part_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Part_binId_fkey" FOREIGN KEY ("binId") REFERENCES "Bin" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_replacementPartId_fkey" FOREIGN KEY ("replacementPartId") REFERENCES "Part" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Part" ("binId", "createdAt", "id", "manufacturerId", "mpn", "notes", "price", "serialNumber", "state", "updatedAt") SELECT "binId", "createdAt", "id", "manufacturerId", "mpn", "notes", "price", "serialNumber", "state", "updatedAt" FROM "Part";
DROP TABLE "Part";
ALTER TABLE "new_Part" RENAME TO "Part";
CREATE UNIQUE INDEX "Part_serialNumber_key" ON "Part"("serialNumber");
CREATE INDEX "Part_state_idx" ON "Part"("state");
CREATE INDEX "Part_binId_idx" ON "Part"("binId");
CREATE INDEX "Part_manufacturerId_idx" ON "Part"("manufacturerId");
CREATE INDEX "Part_mpn_idx" ON "Part"("mpn");
CREATE INDEX "Part_categoryId_idx" ON "Part"("categoryId");
CREATE INDEX "Part_replacementPartId_idx" ON "Part"("replacementPartId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");
-- CreateIndex
CREATE INDEX "PartTag_tagId_idx" ON "PartTag"("tagId");
-- CreateIndex
CREATE UNIQUE INDEX "Host_name_key" ON "Host"("name");
-- CreateIndex
CREATE INDEX "RepairJob_partId_idx" ON "RepairJob"("partId");
-- CreateIndex
CREATE INDEX "RepairJob_status_idx" ON "RepairJob"("status");
-- CreateIndex
CREATE INDEX "RepairJob_hostId_idx" ON "RepairJob"("hostId");
-- CreateIndex
CREATE INDEX "RepairJob_assigneeId_idx" ON "RepairJob"("assigneeId");
-- CreateIndex
CREATE INDEX "RepairJob_status_openedAt_idx" ON "RepairJob"("status", "openedAt" DESC);
-- CreateIndex
CREATE INDEX "WebhookSubscription_active_idx" ON "WebhookSubscription"("active");
-- CreateIndex
CREATE INDEX "SavedView_userId_idx" ON "SavedView"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "SavedView_userId_resource_name_key" ON "SavedView"("userId", "resource", "name");
-- CreateIndex
CREATE INDEX "CsvImportJob_userId_idx" ON "CsvImportJob"("userId");
-- CreateIndex
CREATE INDEX "CsvImportJob_status_idx" ON "CsvImportJob"("status");
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"
+242
View File
@@ -0,0 +1,242 @@
// NOTE: provider is temporarily set to "sqlite" for Phase 1 local verification.
// Flip to "postgresql" once Docker + docker-compose Postgres is available.
// All cascade rules and indexes below are portable between providers.
//
// Postgres-only additions applied post-cutover (see packages/db/POSTGRES_FTS.md):
// * Generated tsvector column on Part(serial, mpn, notes) + GIN index (Phase 3 scope).
// * WebhookSubscription.events and SavedView.filterJson currently stored as String (JSON
// text) for SQLite portability; on Postgres these become String[] and Jsonb respectively.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
username String @unique
email String @unique
passwordHash String
role String @default("TECHNICIAN")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
partEvents PartEvent[]
refreshTokens RefreshToken[]
repairAssignments RepairJob[] @relation("RepairAssignee")
savedViews SavedView[]
csvImportJobs CsvImportJob[]
}
model RefreshToken {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tokenHash String @unique
expiresAt DateTime
revokedAt DateTime?
replacedBy String?
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
}
model Manufacturer {
id String @id @default(uuid())
name String @unique
eolDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parts Part[]
}
model Site {
id String @id @default(uuid())
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rooms Room[]
}
model Room {
id String @id @default(uuid())
name String
siteId String
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bins Bin[]
@@unique([siteId, name])
@@index([siteId])
}
model Bin {
id String @id @default(uuid())
name String
roomId String
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parts Part[]
@@unique([roomId, name])
@@index([roomId])
}
model Category {
id String @id @default(uuid())
name String @unique
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parts Part[]
}
model Part {
id String @id @default(uuid())
serialNumber String @unique
mpn String
manufacturerId String
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
price Float?
state String @default("SPARE")
binId String?
bin Bin? @relation(fields: [binId], references: [id], onDelete: SetNull)
categoryId String?
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
replacementPartId String?
replacement Part? @relation("PartReplacement", fields: [replacementPartId], references: [id], onDelete: SetNull)
replacedBy Part[] @relation("PartReplacement")
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events PartEvent[]
tags PartTag[]
repairs RepairJob[]
@@index([state])
@@index([binId])
@@index([manufacturerId])
@@index([mpn])
@@index([categoryId])
@@index([replacementPartId])
}
model PartEvent {
id String @id @default(uuid())
partId String
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
type String
field String?
oldValue String?
newValue String?
createdAt DateTime @default(now())
@@index([partId, createdAt(sort: Desc)])
@@index([userId])
}
model Tag {
id String @id @default(uuid())
name String @unique
color String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parts PartTag[]
}
model PartTag {
partId String
tagId String
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@id([partId, tagId])
@@index([tagId])
}
model Host {
id String @id @default(uuid())
name String @unique
location String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
repairs RepairJob[]
}
model RepairJob {
id String @id @default(uuid())
partId String
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
hostId String?
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
assigneeId String?
assignee User? @relation("RepairAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
status String @default("PENDING")
openedAt DateTime @default(now())
closedAt DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([partId])
@@index([status])
@@index([hostId])
@@index([assigneeId])
@@index([status, openedAt(sort: Desc)])
}
model WebhookSubscription {
id String @id @default(uuid())
url String
secret String
// JSON array of WebhookEventName values. Becomes String[] on Postgres cutover.
events String
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([active])
}
model SavedView {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
resource String
name String
// JSON blob describing filters/sort/columns. Becomes Jsonb on Postgres cutover.
filterJson String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, resource, name])
@@index([userId])
}
model CsvImportJob {
id String @id @default(uuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
resource String
status String @default("PENDING")
filename String
stagedRows Int @default(0)
// JSON array of CsvImportRowError. Nullable until validation runs.
errors String?
startedAt DateTime?
finishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([status])
}
+27
View File
@@ -0,0 +1,27 @@
import bcrypt from 'bcryptjs';
import { prisma } from '../src/client.js';
async function main() {
const passwordHash = await bcrypt.hash('admin', 12);
const admin = await prisma.user.upsert({
where: { username: 'admin' },
update: {},
create: {
username: 'admin',
email: 'admin@vector.local',
passwordHash,
role: 'ADMIN',
},
});
console.log(`Seeded admin user: ${admin.username} (${admin.email})`);
console.log('Default password: admin — change this immediately!');
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());