chore: initial Vector 2.0 monorepo
Ground-up TypeScript rewrite of the Vector hardware parts inventory
system. Ships the full roadmap (Phases 0-8) in one initial commit:
- pnpm + Turbo monorepo: apps/{api,web,e2e}, packages/{db,shared,ui,config}
- Express 5 + Prisma 5 + zod validation + JWT w/ refresh-token rotation
- React 19 + Vite + shadcn/ui + TanStack Query/Table + nuqs URL state
- Repair/RMA, tags, bulk ops, saved views, CSV audit export
- Analytics dashboard on Recharts + EOL tracking
- Signed webhook subscriptions (HMAC-SHA256) with in-process emitter
- Vitest unit tests (shared schemas, api services/helpers) + Playwright skeleton
- Gitea Actions CI (lint, typecheck, test+coverage, build) + Renovate
Deferred follow-ups: Postgres cutover (data-migration script ready),
BullMQ worker for webhook delivery, @react-pdf PDF export, CSV import wizard.
This commit is contained in:
@@ -0,0 +1,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.
|
||||
@@ -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"
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@vector/config/tsconfig/node.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "prisma"]
|
||||
}
|
||||
Reference in New Issue
Block a user