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
+78
View File
@@ -0,0 +1,78 @@
# Postgres-only migrations (apply post-cutover)
Phase 3 locked in the full schema shape on SQLite. When the datasource flips to
`postgresql` we apply a follow-up migration that upgrades a few columns to
Postgres-native types and adds the Full-Text Search column required by the plan.
## 1. Part full-text search (tsvector + GIN)
```sql
ALTER TABLE "Part"
ADD COLUMN "searchVector" tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('simple', coalesce("serialNumber", '')), 'A') ||
setweight(to_tsvector('simple', coalesce("mpn", '')), 'B') ||
setweight(to_tsvector('english', coalesce("notes", '')), 'C')
) STORED;
CREATE INDEX "Part_searchVector_idx" ON "Part" USING GIN ("searchVector");
```
Query shape the API will use for the `q=` filter on `/api/parts`:
```sql
SELECT * FROM "Part"
WHERE "searchVector" @@ plainto_tsquery('simple', $1)
ORDER BY ts_rank("searchVector", plainto_tsquery('simple', $1)) DESC
LIMIT $2 OFFSET $3;
```
Expose on the Prisma model with `@ignore` (Prisma can't represent `GENERATED`
columns) and read via `prisma.$queryRaw` inside the parts service.
## 2. Convert JSON-string columns to native Postgres types
These were stored as `String` for SQLite portability.
```sql
ALTER TABLE "WebhookSubscription"
ALTER COLUMN "events" TYPE text[] USING string_to_array(
trim(both '[]' from "events"), ','
);
ALTER TABLE "SavedView"
ALTER COLUMN "filterJson" TYPE jsonb USING "filterJson"::jsonb;
ALTER TABLE "CsvImportJob"
ALTER COLUMN "errors" TYPE jsonb USING "errors"::jsonb;
```
After the DDL change, update `packages/db/prisma/schema.prisma`:
```prisma
model WebhookSubscription {
// ...
events String[]
}
model SavedView {
// ...
filterJson Json
}
model CsvImportJob {
// ...
errors Json?
}
```
Regenerate the Prisma client and tighten the zod → Prisma marshaling in the
service layer (drop the `JSON.stringify` / `JSON.parse` bridges).
## Verification checklist (P3)
- `prisma migrate deploy` applies cleanly against a fresh Postgres snapshot.
- `EXPLAIN ANALYZE` on the FTS query shows a `Bitmap Index Scan` on
`Part_searchVector_idx` and returns ranked results in <50ms at 100k rows.
- Integration test inserts 3 parts with overlapping serial/mpn tokens, queries
`q=...`, and asserts order-by-rank.
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@vector/db",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"clean": "rimraf dist .turbo",
"db:generate": "prisma generate --schema=./prisma/schema.prisma",
"db:migrate": "prisma migrate dev --schema=./prisma/schema.prisma",
"db:migrate:deploy": "prisma migrate deploy --schema=./prisma/schema.prisma",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio --schema=./prisma/schema.prisma",
"db:reset": "prisma migrate reset --schema=./prisma/schema.prisma"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"bcryptjs": "^3.0.3"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@vector/config": "workspace:*",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}
@@ -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());
+33
View File
@@ -0,0 +1,33 @@
import { PrismaClient } from '@prisma/client';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
declare global {
// eslint-disable-next-line no-var
var __vectorPrisma: PrismaClient | undefined;
}
function resolveSqliteUrl(raw: string | undefined): string | undefined {
if (!raw || !raw.startsWith('file:')) return raw;
const rest = raw.slice('file:'.length).replace(/^\/+/, '');
if (path.isAbsolute(rest) || /^[A-Za-z]:[\\/]/.test(rest)) {
return 'file:' + rest.replace(/\\/g, '/');
}
const here = path.dirname(fileURLToPath(import.meta.url));
const schemaDir = path.resolve(here, '..', 'prisma');
const absolute = path.resolve(schemaDir, rest);
return 'file:' + absolute.replace(/\\/g, '/');
}
const url = resolveSqliteUrl(process.env.DATABASE_URL);
export const prisma: PrismaClient =
globalThis.__vectorPrisma ??
new PrismaClient({
datasources: url ? { db: { url } } : undefined,
log: process.env.NODE_ENV === 'production' ? ['error'] : ['warn', 'error'],
});
if (process.env.NODE_ENV !== 'production') {
globalThis.__vectorPrisma = prisma;
}
+11
View File
@@ -0,0 +1,11 @@
export { prisma } from './client.js';
export { Prisma, PrismaClient } from '@prisma/client';
export type {
User,
Manufacturer,
Site,
Room,
Bin,
Part,
PartEvent,
} from '@prisma/client';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@vector/config/tsconfig/node.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules", "prisma"]
}