From 0806aec4a4d9f93f51cf2eef021d68c26a6e7468 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 18 Apr 2026 15:52:16 -0400 Subject: [PATCH] Phase 2a: Prisma schema + shared schemas for v1.0 features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 - Test fixtures updated for the new User.notificationPrefs column Co-Authored-By: Claude Opus 4.7 --- server/package-lock.json | 133 ++++++++++++++++++++++++ server/package.json | 9 +- server/prisma/post-push.sql | 52 +++++++++ server/prisma/schema.prisma | 117 +++++++++++++++++---- server/src/services/authService.test.ts | 3 + server/src/services/userService.test.ts | 1 + shared/schemas/attachment.ts | 28 +++++ shared/schemas/index.ts | 3 + shared/schemas/notification.ts | 50 +++++++++ shared/schemas/savedView.ts | 28 +++++ shared/schemas/ticket.ts | 23 ++++ shared/types.ts | 52 ++++++++- 12 files changed, 475 insertions(+), 24 deletions(-) create mode 100644 server/prisma/post-push.sql create mode 100644 shared/schemas/attachment.ts create mode 100644 shared/schemas/notification.ts create mode 100644 shared/schemas/savedView.ts diff --git a/server/package-lock.json b/server/package-lock.json index 0b706b2..0a3f636 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index 089d724..b48f0a4 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/prisma/post-push.sql b/server/prisma/post-push.sql new file mode 100644 index 0000000..0ee793f --- /dev/null +++ b/server/prisma/post-push.sql @@ -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; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index ad15470..656d922 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -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 { diff --git a/server/src/services/authService.test.ts b/server/src/services/authService.test.ts index c341275..339c6d3 100644 --- a/server/src/services/authService.test.ts +++ b/server/src/services/authService.test.ts @@ -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(), }); diff --git a/server/src/services/userService.test.ts b/server/src/services/userService.test.ts index 3591e28..35a687a 100644 --- a/server/src/services/userService.test.ts +++ b/server/src/services/userService.test.ts @@ -11,6 +11,7 @@ const stubUser = { role: 'AGENT' as const, passwordHash: '', apiKey: null, + notificationPrefs: null, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/shared/schemas/attachment.ts b/shared/schemas/attachment.ts new file mode 100644 index 0000000..0471a70 --- /dev/null +++ b/shared/schemas/attachment.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +export const ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024; + +export const ATTACHMENT_MIME_ALLOWLIST = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'image/svg+xml', + 'application/pdf', + 'application/zip', + 'application/json', + 'application/x-yaml', + 'text/plain', + 'text/csv', + 'text/markdown', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/msword', + 'application/vnd.ms-excel', + 'application/octet-stream', +]; + +export const attachmentTargetSchema = z.object({ + ticketId: z.string().cuid().optional(), + commentId: z.string().cuid().optional(), +}); diff --git a/shared/schemas/index.ts b/shared/schemas/index.ts index 1692171..098a2e2 100644 --- a/shared/schemas/index.ts +++ b/shared/schemas/index.ts @@ -4,3 +4,6 @@ export * from './ticket'; export * from './comment'; export * from './user'; export * from './cti'; +export * from './attachment'; +export * from './savedView'; +export * from './notification'; diff --git a/shared/schemas/notification.ts b/shared/schemas/notification.ts new file mode 100644 index 0000000..71a6fa4 --- /dev/null +++ b/shared/schemas/notification.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +export const notificationPrefsSchema = z.object({ + email: z + .object({ + assignment: z.boolean().default(true), + mention: z.boolean().default(true), + resolved: z.boolean().default(false), + }) + .default({}), + inApp: z + .object({ + assignment: z.boolean().default(true), + mention: z.boolean().default(true), + resolved: z.boolean().default(true), + }) + .default({}), +}); + +export const markReadSchema = z.object({ + ids: z.array(z.string().min(1)).optional(), + all: z.boolean().optional(), +}); + +export const createWebhookSchema = z.object({ + name: z.string().min(1).max(80), + url: z.string().url(), + events: z.array(z.string().min(1)).min(1), + active: z.boolean().default(true), +}); + +export const updateWebhookSchema = z.object({ + name: z.string().min(1).max(80).optional(), + url: z.string().url().optional(), + events: z.array(z.string().min(1)).min(1).optional(), + active: z.boolean().optional(), +}); + +export const WEBHOOK_EVENTS = [ + 'ticket.created', + 'ticket.status_changed', + 'ticket.assigned', + 'ticket.resolved', + 'comment.created', +] as const; + +export type NotificationPrefs = z.infer; +export type CreateWebhookInput = z.infer; +export type UpdateWebhookInput = z.infer; +export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]; diff --git a/shared/schemas/savedView.ts b/shared/schemas/savedView.ts new file mode 100644 index 0000000..793cc7a --- /dev/null +++ b/shared/schemas/savedView.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +export const savedViewFiltersSchema = z + .object({ + status: z.string().optional(), + severity: z.number().optional(), + assigneeId: z.string().optional(), + createdById: z.string().optional(), + categoryId: z.string().optional(), + typeId: z.string().optional(), + itemId: z.string().optional(), + search: z.string().optional(), + }) + .passthrough(); + +export const createSavedViewSchema = z.object({ + name: z.string().min(1).max(80), + filters: savedViewFiltersSchema, +}); + +export const updateSavedViewSchema = z.object({ + name: z.string().min(1).max(80).optional(), + filters: savedViewFiltersSchema.optional(), +}); + +export type CreateSavedViewInput = z.infer; +export type UpdateSavedViewInput = z.infer; +export type SavedViewFilters = z.infer; diff --git a/shared/schemas/ticket.ts b/shared/schemas/ticket.ts index 69ccacd..c3272ab 100644 --- a/shared/schemas/ticket.ts +++ b/shared/schemas/ticket.ts @@ -22,5 +22,28 @@ export const updateTicketSchema = z.object({ assigneeId: z.string().nullable().optional(), }); +export const bulkActionSchema = z.discriminatedUnion('action', [ + z.object({ + action: z.literal('reassign'), + ids: z.array(z.string().min(1)).min(1).max(500), + value: z.string().nullable(), + }), + z.object({ + action: z.literal('close'), + ids: z.array(z.string().min(1)).min(1).max(500), + }), + z.object({ + action: z.literal('setSeverity'), + ids: z.array(z.string().min(1)).min(1).max(500), + value: severitySchema, + }), + z.object({ + action: z.literal('setStatus'), + ids: z.array(z.string().min(1)).min(1).max(500), + value: ticketStatusSchema, + }), +]); + export type CreateTicketInput = z.infer; export type UpdateTicketInput = z.infer; +export type BulkActionInput = z.infer; diff --git a/shared/types.ts b/shared/types.ts index 791bf0d..e143d47 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -74,5 +74,55 @@ export interface Ticket { assignee: UserSummary | null; createdBy: UserSummary; comments?: Comment[]; - _count?: { comments: number }; + _count?: { comments: number; attachments?: number }; +} + +export interface Attachment { + id: string; + filename: string; + mimetype: string; + size: number; + ticketId: string | null; + commentId: string | null; + uploadedById: string; + uploadedBy: UserSummary; + createdAt: string; +} + +export interface Notification { + id: string; + userId: string; + kind: string; + ticketId: string | null; + commentId: string | null; + data: unknown; + readAt: string | null; + createdAt: string; +} + +export interface SavedView { + id: string; + userId: string; + name: string; + filters: Record; + createdAt: string; + updatedAt: string; +} + +export interface Webhook { + id: string; + name: string; + url: string; + events: string[]; + secret?: string; + active: boolean; + createdAt: string; + updatedAt: string; +} + +export interface PaginatedResponse { + data: T[]; + total: number; + page: number; + pageSize: number; }