Phase 2a: Prisma schema + shared schemas for v1.0 features

- New models: Attachment, Webhook, Notification, SavedView
- New fields: User.notificationPrefs (Json), indexes on Ticket
- post-push.sql manages the tsvector columns + GIN indexes + triggers for
  FTS on Ticket (title/overview/displayId) and Comment (body); Prisma can't
  express these
- package.json scripts: db:push and start:prod now chain `prisma db execute`
  against post-push.sql after `prisma db push`
- db:migrate script removed — project uses push workflow, not migrations
- Shared Zod schemas: attachment (25MB limit + mimetype allowlist), savedView,
  notification (prefs, mark-read, webhook CRUD)
- Shared type additions: Attachment, Notification, SavedView, Webhook,
  PaginatedResponse<T>
- Test fixtures updated for the new User.notificationPrefs column

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 15:52:16 -04:00
parent 77679922a8
commit 0806aec4a4
12 changed files with 475 additions and 24 deletions
+133
View File
@@ -16,7 +16,9 @@
"express-async-errors": "^3.1.0",
"express-rate-limit": "^7.5.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"node-cron": "^3.0.3",
"nodemailer": "^8.0.5",
"pino": "^9.5.0",
"pino-http": "^10.3.0",
"pino-pretty": "^13.0.0",
@@ -27,8 +29,10 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^2.1.0",
"@types/node": "^22.10.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^8.0.0",
"@types/supertest": "^6.0.2",
"prisma": "^5.22.0",
"supertest": "^7.0.0",
@@ -1090,6 +1094,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
@@ -1107,6 +1121,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/nodemailer": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
@@ -1304,6 +1328,12 @@
"node": ">= 0.6"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -1379,6 +1409,23 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1483,6 +1530,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -2342,6 +2404,25 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -2382,6 +2463,15 @@
"node": ">=6.0.0"
}
},
"node_modules/nodemailer": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -2699,6 +2789,20 @@
"node": ">= 0.8"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
@@ -3007,6 +3111,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/superagent": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
@@ -3201,6 +3322,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -3231,6 +3358,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+6 -3
View File
@@ -5,9 +5,8 @@
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/server/src/index.js",
"start:prod": "prisma db push && node dist/server/src/index.js",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"start:prod": "prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma && node dist/server/src/index.js",
"db:push": "prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma",
"db:generate": "prisma generate",
"db:seed": "tsx prisma/seed.ts",
"typecheck": "tsc --noEmit",
@@ -23,7 +22,9 @@
"express-async-errors": "^3.1.0",
"express-rate-limit": "^7.5.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"node-cron": "^3.0.3",
"nodemailer": "^8.0.5",
"pino": "^9.5.0",
"pino-http": "^10.3.0",
"pino-pretty": "^13.0.0",
@@ -34,8 +35,10 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^2.1.0",
"@types/node": "^22.10.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^8.0.0",
"@types/supertest": "^6.0.2",
"prisma": "^5.22.0",
"supertest": "^7.0.0",
+52
View File
@@ -0,0 +1,52 @@
-- Idempotent SQL applied after `prisma db push`.
-- Adds Postgres full-text-search columns + triggers + GIN indexes for Ticket and Comment.
-- Prisma can't express tsvector/triggers, so we manage them here.
-- Ticket.searchVector
ALTER TABLE "Ticket" ADD COLUMN IF NOT EXISTS "searchVector" tsvector;
CREATE INDEX IF NOT EXISTS ticket_search_idx ON "Ticket" USING GIN ("searchVector");
CREATE OR REPLACE FUNCTION ticket_search_trigger() RETURNS trigger AS $$
BEGIN
NEW."searchVector" :=
setweight(to_tsvector('english', coalesce(NEW."displayId", '')), 'A') ||
setweight(to_tsvector('english', coalesce(NEW."title", '')), 'A') ||
setweight(to_tsvector('english', coalesce(NEW."overview", '')), 'B');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS ticket_search_update ON "Ticket";
CREATE TRIGGER ticket_search_update
BEFORE INSERT OR UPDATE OF "title", "overview", "displayId" ON "Ticket"
FOR EACH ROW EXECUTE FUNCTION ticket_search_trigger();
-- Backfill any rows missing the vector (first run or new columns)
UPDATE "Ticket"
SET "searchVector" =
setweight(to_tsvector('english', coalesce("displayId", '')), 'A') ||
setweight(to_tsvector('english', coalesce("title", '')), 'A') ||
setweight(to_tsvector('english', coalesce("overview", '')), 'B')
WHERE "searchVector" IS NULL;
-- Comment.searchVector
ALTER TABLE "Comment" ADD COLUMN IF NOT EXISTS "searchVector" tsvector;
CREATE INDEX IF NOT EXISTS comment_search_idx ON "Comment" USING GIN ("searchVector");
CREATE OR REPLACE FUNCTION comment_search_trigger() RETURNS trigger AS $$
BEGIN
NEW."searchVector" := to_tsvector('english', coalesce(NEW."body", ''));
RETURN NEW;
END
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS comment_search_update ON "Comment";
CREATE TRIGGER comment_search_update
BEFORE INSERT OR UPDATE OF "body" ON "Comment"
FOR EACH ROW EXECUTE FUNCTION comment_search_trigger();
UPDATE "Comment"
SET "searchVector" = to_tsvector('english', coalesce("body", ''))
WHERE "searchVector" IS NULL;
+97 -20
View File
@@ -22,20 +22,24 @@ enum TicketStatus {
}
model User {
id String @id @default(cuid())
username String @unique
email String @unique
passwordHash String
displayName String
role Role @default(AGENT)
apiKey String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
username String @unique
email String @unique
passwordHash String
displayName String
role Role @default(AGENT)
apiKey String? @unique
notificationPrefs Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignedTickets Ticket[] @relation("AssignedTickets")
createdTickets Ticket[] @relation("CreatedTickets")
assignedTickets Ticket[] @relation("AssignedTickets")
createdTickets Ticket[] @relation("CreatedTickets")
comments Comment[]
auditLogs AuditLog[]
attachments Attachment[]
notifications Notification[]
savedViews SavedView[]
}
model Category {
@@ -83,13 +87,21 @@ model Ticket {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
category Category @relation(fields: [categoryId], references: [id])
type Type @relation(fields: [typeId], references: [id])
item Item @relation(fields: [itemId], references: [id])
assignee User? @relation("AssignedTickets", fields: [assigneeId], references: [id])
createdBy User @relation("CreatedTickets", fields: [createdById], references: [id])
comments Comment[]
auditLogs AuditLog[]
category Category @relation(fields: [categoryId], references: [id])
type Type @relation(fields: [typeId], references: [id])
item Item @relation(fields: [itemId], references: [id])
assignee User? @relation("AssignedTickets", fields: [assigneeId], references: [id])
createdBy User @relation("CreatedTickets", fields: [createdById], references: [id])
comments Comment[]
auditLogs AuditLog[]
attachments Attachment[]
notifications Notification[]
@@index([status])
@@index([severity])
@@index([assigneeId])
@@index([createdById])
@@index([createdAt])
}
model Comment {
@@ -99,8 +111,73 @@ model Comment {
authorId String
createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id])
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id])
attachments Attachment[]
notifications Notification[]
@@index([ticketId])
}
model Attachment {
id String @id @default(cuid())
filename String
mimetype String
size Int
storagePath String
ticketId String?
commentId String?
uploadedById String
createdAt DateTime @default(now())
ticket Ticket? @relation(fields: [ticketId], references: [id], onDelete: Cascade)
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
uploadedBy User @relation(fields: [uploadedById], references: [id])
@@index([ticketId])
@@index([commentId])
}
model Webhook {
id String @id @default(cuid())
name String
url String
events String[]
secret String
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Notification {
id String @id @default(cuid())
userId String
kind String
ticketId String?
commentId String?
data Json?
readAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ticket Ticket? @relation(fields: [ticketId], references: [id], onDelete: Cascade)
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
@@index([userId, readAt])
@@index([userId, createdAt])
}
model SavedView {
id String @id @default(cuid())
userId String
name String
filters Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, name])
}
model AuditLog {
+3
View File
@@ -16,6 +16,7 @@ describe('authService.login', () => {
passwordHash: await bcrypt.hash(password, 4),
role: 'AGENT',
apiKey: null,
notificationPrefs: null,
createdAt: new Date(),
updatedAt: new Date(),
});
@@ -35,6 +36,7 @@ describe('authService.login', () => {
passwordHash: await bcrypt.hash('correct', 4),
role: 'AGENT',
apiKey: null,
notificationPrefs: null,
createdAt: new Date(),
updatedAt: new Date(),
});
@@ -59,6 +61,7 @@ describe('authService.login', () => {
passwordHash: await bcrypt.hash(password, 4),
role: 'SERVICE',
apiKey: 'sk_xyz',
notificationPrefs: null,
createdAt: new Date(),
updatedAt: new Date(),
});
+1
View File
@@ -11,6 +11,7 @@ const stubUser = {
role: 'AGENT' as const,
passwordHash: '',
apiKey: null,
notificationPrefs: null,
createdAt: new Date(),
updatedAt: new Date(),
};