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-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
View File
@@ -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",
+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 { model User {
id String @id @default(cuid()) id String @id @default(cuid())
username String @unique username String @unique
email String @unique email String @unique
passwordHash String passwordHash String
displayName String displayName String
role Role @default(AGENT) role Role @default(AGENT)
apiKey String? @unique apiKey String? @unique
createdAt DateTime @default(now()) notificationPrefs Json?
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignedTickets Ticket[] @relation("AssignedTickets") assignedTickets Ticket[] @relation("AssignedTickets")
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 {
@@ -83,13 +87,21 @@ model Ticket {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
category Category @relation(fields: [categoryId], references: [id]) category Category @relation(fields: [categoryId], references: [id])
type Type @relation(fields: [typeId], references: [id]) type Type @relation(fields: [typeId], references: [id])
item Item @relation(fields: [itemId], references: [id]) item Item @relation(fields: [itemId], references: [id])
assignee User? @relation("AssignedTickets", fields: [assigneeId], references: [id]) assignee User? @relation("AssignedTickets", fields: [assigneeId], references: [id])
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 {
@@ -99,8 +111,73 @@ model Comment {
authorId String authorId String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
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 {
+3
View File
@@ -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(),
}); });
+1
View File
@@ -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(),
}; };
+28
View File
@@ -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(),
});
+3
View File
@@ -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';
+50
View File
@@ -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];
+28
View File
@@ -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>;
+23
View File
@@ -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
View File
@@ -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;
} }