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:
Generated
+133
@@ -16,7 +16,9 @@
|
|||||||
"express-async-errors": "^3.1.0",
|
"express-async-errors": "^3.1.0",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
|
"nodemailer": "^8.0.5",
|
||||||
"pino": "^9.5.0",
|
"pino": "^9.5.0",
|
||||||
"pino-http": "^10.3.0",
|
"pino-http": "^10.3.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
@@ -27,8 +29,10 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
@@ -1090,6 +1094,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.15",
|
"version": "22.19.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
@@ -1107,6 +1121,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||||
@@ -1304,6 +1328,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
@@ -1379,6 +1409,23 @@
|
|||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -1483,6 +1530,21 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -2342,6 +2404,25 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -2382,6 +2463,15 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -2699,6 +2789,20 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/real-require": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||||
@@ -3007,6 +3111,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/superagent": {
|
||||||
"version": "10.3.0",
|
"version": "10.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
|
||||||
@@ -3201,6 +3322,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -3231,6 +3358,12 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
|||||||
+6
-3
@@ -5,9 +5,8 @@
|
|||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/server/src/index.js",
|
"start": "node dist/server/src/index.js",
|
||||||
"start:prod": "prisma db push && node dist/server/src/index.js",
|
"start:prod": "prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma && node dist/server/src/index.js",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:push": "prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma",
|
||||||
"db:push": "prisma db push",
|
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
@@ -23,7 +22,9 @@
|
|||||||
"express-async-errors": "^3.1.0",
|
"express-async-errors": "^3.1.0",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
|
"nodemailer": "^8.0.5",
|
||||||
"pino": "^9.5.0",
|
"pino": "^9.5.0",
|
||||||
"pino-http": "^10.3.0",
|
"pino-http": "^10.3.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
@@ -34,8 +35,10 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -29,6 +29,7 @@ model User {
|
|||||||
displayName String
|
displayName String
|
||||||
role Role @default(AGENT)
|
role Role @default(AGENT)
|
||||||
apiKey String? @unique
|
apiKey String? @unique
|
||||||
|
notificationPrefs Json?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -36,6 +37,9 @@ model User {
|
|||||||
createdTickets Ticket[] @relation("CreatedTickets")
|
createdTickets Ticket[] @relation("CreatedTickets")
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
|
attachments Attachment[]
|
||||||
|
notifications Notification[]
|
||||||
|
savedViews SavedView[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Category {
|
model Category {
|
||||||
@@ -90,6 +94,14 @@ model Ticket {
|
|||||||
createdBy User @relation("CreatedTickets", fields: [createdById], references: [id])
|
createdBy User @relation("CreatedTickets", fields: [createdById], references: [id])
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
|
attachments Attachment[]
|
||||||
|
notifications Notification[]
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
@@index([severity])
|
||||||
|
@@index([assigneeId])
|
||||||
|
@@index([createdById])
|
||||||
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
@@ -101,6 +113,71 @@ model Comment {
|
|||||||
|
|
||||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||||
author User @relation(fields: [authorId], references: [id])
|
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 {
|
model AuditLog {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe('authService.login', () => {
|
|||||||
passwordHash: await bcrypt.hash(password, 4),
|
passwordHash: await bcrypt.hash(password, 4),
|
||||||
role: 'AGENT',
|
role: 'AGENT',
|
||||||
apiKey: null,
|
apiKey: null,
|
||||||
|
notificationPrefs: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
@@ -35,6 +36,7 @@ describe('authService.login', () => {
|
|||||||
passwordHash: await bcrypt.hash('correct', 4),
|
passwordHash: await bcrypt.hash('correct', 4),
|
||||||
role: 'AGENT',
|
role: 'AGENT',
|
||||||
apiKey: null,
|
apiKey: null,
|
||||||
|
notificationPrefs: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
@@ -59,6 +61,7 @@ describe('authService.login', () => {
|
|||||||
passwordHash: await bcrypt.hash(password, 4),
|
passwordHash: await bcrypt.hash(password, 4),
|
||||||
role: 'SERVICE',
|
role: 'SERVICE',
|
||||||
apiKey: 'sk_xyz',
|
apiKey: 'sk_xyz',
|
||||||
|
notificationPrefs: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const stubUser = {
|
|||||||
role: 'AGENT' as const,
|
role: 'AGENT' as const,
|
||||||
passwordHash: '',
|
passwordHash: '',
|
||||||
apiKey: null,
|
apiKey: null,
|
||||||
|
notificationPrefs: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
@@ -4,3 +4,6 @@ export * from './ticket';
|
|||||||
export * from './comment';
|
export * from './comment';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
export * from './cti';
|
export * from './cti';
|
||||||
|
export * from './attachment';
|
||||||
|
export * from './savedView';
|
||||||
|
export * from './notification';
|
||||||
|
|||||||
@@ -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<typeof notificationPrefsSchema>;
|
||||||
|
export type CreateWebhookInput = z.infer<typeof createWebhookSchema>;
|
||||||
|
export type UpdateWebhookInput = z.infer<typeof updateWebhookSchema>;
|
||||||
|
export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number];
|
||||||
@@ -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<typeof createSavedViewSchema>;
|
||||||
|
export type UpdateSavedViewInput = z.infer<typeof updateSavedViewSchema>;
|
||||||
|
export type SavedViewFilters = z.infer<typeof savedViewFiltersSchema>;
|
||||||
@@ -22,5 +22,28 @@ export const updateTicketSchema = z.object({
|
|||||||
assigneeId: z.string().nullable().optional(),
|
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<typeof createTicketSchema>;
|
export type CreateTicketInput = z.infer<typeof createTicketSchema>;
|
||||||
export type UpdateTicketInput = z.infer<typeof updateTicketSchema>;
|
export type UpdateTicketInput = z.infer<typeof updateTicketSchema>;
|
||||||
|
export type BulkActionInput = z.infer<typeof bulkActionSchema>;
|
||||||
|
|||||||
+51
-1
@@ -74,5 +74,55 @@ export interface Ticket {
|
|||||||
assignee: UserSummary | null;
|
assignee: UserSummary | null;
|
||||||
createdBy: UserSummary;
|
createdBy: UserSummary;
|
||||||
comments?: Comment[];
|
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<string, unknown>;
|
||||||
|
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<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user