Phase 1a: shared schemas, service layer, server tooling
- shared/schemas/: move Zod schemas out of routes so client + server share them - shared/types.ts: inferred types and enums for cross-package use - server tsconfig rootDir raised to ".." so shared/ compiles in-tree - server/src/services/: ticket, comment, cti, user, auth, notification (stub), search (stub) - Routes thinned to validate-delegate-return; business logic now testable in isolation - server/src/lib/httpError.ts: typed HttpError replaces ad-hoc throw shapes - server/src/lib/logger.ts: pino structured logging replaces console.log - autoClose job delegates to ticketService.closeStale() - express-rate-limit on /api/auth/login (10 / 15min / IP) - vitest + vitest-mock-extended; 20 service-level tests cover auth, ticket, comment, user flows - CI: lint + test jobs before docker builds Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+10
-10
@@ -1,16 +1,16 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
COPY server/package*.json ./server/
|
||||
RUN cd server && npm ci
|
||||
COPY server ./server
|
||||
COPY shared ./shared
|
||||
RUN cd server && npx prisma generate && npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
RUN apk add --no-cache openssl
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/prisma ./prisma
|
||||
COPY package*.json ./
|
||||
WORKDIR /app/server
|
||||
COPY --from=build /app/server/dist ./dist
|
||||
COPY --from=build /app/server/node_modules ./node_modules
|
||||
COPY --from=build /app/server/prisma ./prisma
|
||||
COPY --from=build /app/server/package*.json ./
|
||||
CMD ["npm", "run", "start:prod"]
|
||||
|
||||
Generated
+33
-1619
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -4,8 +4,8 @@
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"start:prod": "prisma db push && node dist/index.js",
|
||||
"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",
|
||||
"db:generate": "prisma generate",
|
||||
@@ -41,6 +41,7 @@
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
"vitest": "^2.1.8",
|
||||
"vitest-mock-extended": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+15
-2
@@ -2,6 +2,8 @@ import 'express-async-errors';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import pinoHttp from 'pino-http';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
import authRoutes from './routes/auth';
|
||||
import ticketRoutes from './routes/tickets';
|
||||
@@ -10,20 +12,31 @@ import userRoutes from './routes/users';
|
||||
import { authenticate } from './middleware/auth';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
import { startAutoCloseJob } from './jobs/autoClose';
|
||||
import { logger } from './lib/logger';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
console.error('FATAL: JWT_SECRET is not set');
|
||||
logger.fatal('JWT_SECRET is not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(pinoHttp({ logger }));
|
||||
app.use(cors({ origin: process.env.CLIENT_URL || 'http://localhost:5173' }));
|
||||
app.use(express.json());
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
|
||||
});
|
||||
|
||||
// Public
|
||||
app.use('/api/auth/login', loginLimiter);
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// Protected
|
||||
@@ -37,5 +50,5 @@ startAutoCloseJob();
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
logger.info({ port: PORT }, 'Server started');
|
||||
});
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
import cron from 'node-cron';
|
||||
import prisma from '../lib/prisma';
|
||||
import { closeStale } from '../services/ticketService';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
export function startAutoCloseJob() {
|
||||
// Run every hour — closes RESOLVED tickets that have been resolved for 14+ days
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
const twoWeeksAgo = new Date();
|
||||
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14);
|
||||
|
||||
const result = await prisma.ticket.updateMany({
|
||||
where: {
|
||||
status: 'RESOLVED',
|
||||
resolvedAt: { lte: twoWeeksAgo },
|
||||
},
|
||||
data: { status: 'CLOSED' },
|
||||
});
|
||||
|
||||
if (result.count > 0) {
|
||||
console.log(`[AutoClose] Closed ${result.count} ticket(s) after 2-week resolution period`);
|
||||
const count = await closeStale(14);
|
||||
if (count > 0) {
|
||||
logger.info({ count }, 'AutoClose: closed stale resolved tickets');
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[AutoClose] Job scheduled — runs every hour');
|
||||
logger.info('AutoClose: job scheduled — runs every hour');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import pino from 'pino';
|
||||
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL ?? (isDev ? 'debug' : 'info'),
|
||||
transport: isDev
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: { colorize: true, translateTime: 'SYS:HH:MM:ss.l', ignore: 'pid,hostname' },
|
||||
}
|
||||
: undefined,
|
||||
redact: {
|
||||
paths: ['req.headers.authorization', 'req.headers["x-api-key"]', '*.password', '*.passwordHash'],
|
||||
remove: true,
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
type ErrorLike = {
|
||||
code?: string;
|
||||
@@ -9,7 +10,7 @@ type ErrorLike = {
|
||||
};
|
||||
|
||||
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
|
||||
console.error(err);
|
||||
logger.error({ err }, 'Request failed');
|
||||
|
||||
if (err instanceof ZodError) {
|
||||
return res.status(400).json({ error: 'Validation error', details: err.flatten() });
|
||||
|
||||
@@ -1,53 +1,19 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { z } from 'zod';
|
||||
import prisma from '../lib/prisma';
|
||||
import { authenticate, AuthRequest } from '../middleware/auth';
|
||||
import { loginSchema } from '../../../shared/schemas/auth';
|
||||
import * as authService from '../services/authService';
|
||||
import * as userService from '../services/userService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
const { username, password } = loginSchema.parse(req.body);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
if (user.role === 'SERVICE') {
|
||||
return res.status(401).json({ error: 'Service accounts must authenticate via API key' });
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, role: user.role, username: user.username },
|
||||
process.env.JWT_SECRET!,
|
||||
{ expiresIn: '24h' },
|
||||
);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
});
|
||||
const input = loginSchema.parse(req.body);
|
||||
const result = await authService.login(input);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get('/me', authenticate, async (req: AuthRequest, res) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id },
|
||||
select: { id: true, username: true, displayName: true, email: true, role: true },
|
||||
});
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
const user = await userService.getCurrentUser(req.user!.id);
|
||||
res.json(user);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,58 +1,22 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import prisma from '../lib/prisma';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { commentSchema } from '../../../shared/schemas/comment';
|
||||
import * as commentService from '../services/commentService';
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
|
||||
const commentSchema = z.object({
|
||||
body: z.string().min(1),
|
||||
});
|
||||
|
||||
router.post('/', async (req: AuthRequest, res) => {
|
||||
const { body } = commentSchema.parse(req.body);
|
||||
const ticketId = (req.params as Record<string, string>).ticketId;
|
||||
|
||||
const ticket = await prisma.ticket.findFirst({
|
||||
where: { OR: [{ id: ticketId }, { displayId: ticketId }] },
|
||||
});
|
||||
if (!ticket) return res.status(404).json({ error: 'Ticket not found' });
|
||||
|
||||
const [comment] = await prisma.$transaction([
|
||||
prisma.comment.create({
|
||||
data: { body, ticketId: ticket.id, authorId: req.user!.id },
|
||||
include: { author: { select: { id: true, username: true, displayName: true } } },
|
||||
}),
|
||||
prisma.auditLog.create({
|
||||
data: { ticketId: ticket.id, userId: req.user!.id, action: 'COMMENT_ADDED', detail: body },
|
||||
}),
|
||||
]);
|
||||
|
||||
const comment = await commentService.addComment(ticketId, body, req.user!.id);
|
||||
res.status(201).json(comment);
|
||||
});
|
||||
|
||||
router.delete('/:commentId', async (req: AuthRequest, res) => {
|
||||
const comment = await prisma.comment.findUnique({
|
||||
where: { id: req.params.commentId },
|
||||
await commentService.deleteComment(req.params.commentId, {
|
||||
id: req.user!.id,
|
||||
role: req.user!.role,
|
||||
});
|
||||
if (!comment) return res.status(404).json({ error: 'Comment not found' });
|
||||
|
||||
if (comment.authorId !== req.user!.id && req.user!.role !== 'ADMIN') {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.comment.delete({ where: { id: req.params.commentId } }),
|
||||
prisma.auditLog.create({
|
||||
data: {
|
||||
ticketId: comment.ticketId,
|
||||
userId: req.user!.id,
|
||||
action: 'COMMENT_DELETED',
|
||||
detail: comment.body,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
|
||||
+23
-61
@@ -1,112 +1,74 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import prisma from '../lib/prisma';
|
||||
import { requireAdmin } from '../middleware/auth';
|
||||
import {
|
||||
ctiNameSchema as nameSchema,
|
||||
createTypeSchema,
|
||||
createItemSchema,
|
||||
} from '../../../shared/schemas/cti';
|
||||
import * as ctiService from '../services/ctiService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const nameSchema = z.object({ name: z.string().min(1).max(100) });
|
||||
|
||||
// ── Categories ────────────────────────────────────────────────────────────────
|
||||
// ── Categories ───────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/categories', async (_req, res) => {
|
||||
const categories = await prisma.category.findMany({ orderBy: { name: 'asc' } });
|
||||
res.json(categories);
|
||||
res.json(await ctiService.listCategories());
|
||||
});
|
||||
|
||||
router.post('/categories', requireAdmin, async (req, res) => {
|
||||
const { name } = nameSchema.parse(req.body);
|
||||
const category = await prisma.category.create({ data: { name } });
|
||||
res.status(201).json(category);
|
||||
res.status(201).json(await ctiService.createCategory(name));
|
||||
});
|
||||
|
||||
router.put('/categories/:id', requireAdmin, async (req, res) => {
|
||||
const { name } = nameSchema.parse(req.body);
|
||||
const category = await prisma.category.update({
|
||||
where: { id: req.params.id },
|
||||
data: { name },
|
||||
});
|
||||
res.json(category);
|
||||
res.json(await ctiService.updateCategory(req.params.id, name));
|
||||
});
|
||||
|
||||
router.delete('/categories/:id', requireAdmin, async (req, res) => {
|
||||
await prisma.category.delete({ where: { id: req.params.id } });
|
||||
await ctiService.deleteCategory(req.params.id);
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/types', async (req, res) => {
|
||||
const { categoryId } = req.query;
|
||||
const types = await prisma.type.findMany({
|
||||
where: categoryId ? { categoryId: categoryId as string } : undefined,
|
||||
include: { category: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
res.json(types);
|
||||
res.json(await ctiService.listTypes(req.query.categoryId as string | undefined));
|
||||
});
|
||||
|
||||
router.post('/types', requireAdmin, async (req, res) => {
|
||||
const { name, categoryId } = z
|
||||
.object({ name: z.string().min(1).max(100), categoryId: z.string().min(1) })
|
||||
.parse(req.body);
|
||||
const type = await prisma.type.create({
|
||||
data: { name, categoryId },
|
||||
include: { category: true },
|
||||
});
|
||||
res.status(201).json(type);
|
||||
const { name, categoryId } = createTypeSchema.parse(req.body);
|
||||
res.status(201).json(await ctiService.createType(name, categoryId));
|
||||
});
|
||||
|
||||
router.put('/types/:id', requireAdmin, async (req, res) => {
|
||||
const { name } = nameSchema.parse(req.body);
|
||||
const type = await prisma.type.update({
|
||||
where: { id: req.params.id },
|
||||
data: { name },
|
||||
include: { category: true },
|
||||
});
|
||||
res.json(type);
|
||||
res.json(await ctiService.updateType(req.params.id, name));
|
||||
});
|
||||
|
||||
router.delete('/types/:id', requireAdmin, async (req, res) => {
|
||||
await prisma.type.delete({ where: { id: req.params.id } });
|
||||
await ctiService.deleteType(req.params.id);
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
// ── Items ─────────────────────────────────────────────────────────────────────
|
||||
// ── Items ────────────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/items', async (req, res) => {
|
||||
const { typeId } = req.query;
|
||||
const items = await prisma.item.findMany({
|
||||
where: typeId ? { typeId: typeId as string } : undefined,
|
||||
include: { type: { include: { category: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
res.json(items);
|
||||
res.json(await ctiService.listItems(req.query.typeId as string | undefined));
|
||||
});
|
||||
|
||||
router.post('/items', requireAdmin, async (req, res) => {
|
||||
const { name, typeId } = z
|
||||
.object({ name: z.string().min(1).max(100), typeId: z.string().min(1) })
|
||||
.parse(req.body);
|
||||
const item = await prisma.item.create({
|
||||
data: { name, typeId },
|
||||
include: { type: { include: { category: true } } },
|
||||
});
|
||||
res.status(201).json(item);
|
||||
const { name, typeId } = createItemSchema.parse(req.body);
|
||||
res.status(201).json(await ctiService.createItem(name, typeId));
|
||||
});
|
||||
|
||||
router.put('/items/:id', requireAdmin, async (req, res) => {
|
||||
const { name } = nameSchema.parse(req.body);
|
||||
const item = await prisma.item.update({
|
||||
where: { id: req.params.id },
|
||||
data: { name },
|
||||
include: { type: { include: { category: true } } },
|
||||
});
|
||||
res.json(item);
|
||||
res.json(await ctiService.updateItem(req.params.id, name));
|
||||
});
|
||||
|
||||
router.delete('/items/:id', requireAdmin, async (req, res) => {
|
||||
await prisma.item.delete({ where: { id: req.params.id } });
|
||||
await ctiService.deleteItem(req.params.id);
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
|
||||
+19
-228
@@ -1,263 +1,54 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import prisma from '../lib/prisma';
|
||||
import { requireAdmin, requireAgent, AuthRequest } from '../middleware/auth';
|
||||
import commentRouter from './comments';
|
||||
import { createTicketSchema, updateTicketSchema } from '../../../shared/schemas/ticket';
|
||||
import * as ticketService from '../services/ticketService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const ticketInclude = {
|
||||
category: true,
|
||||
type: true,
|
||||
item: true,
|
||||
assignee: { select: { id: true, username: true, displayName: true } },
|
||||
createdBy: { select: { id: true, username: true, displayName: true } },
|
||||
comments: {
|
||||
include: { author: { select: { id: true, username: true, displayName: true } } },
|
||||
orderBy: { createdAt: 'asc' as const },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
OPEN: 'Open',
|
||||
IN_PROGRESS: 'In Progress',
|
||||
RESOLVED: 'Resolved',
|
||||
CLOSED: 'Closed',
|
||||
};
|
||||
|
||||
async function generateDisplayId(): Promise<string> {
|
||||
while (true) {
|
||||
const num = Math.floor(Math.random() * 900_000_000) + 100_000_000;
|
||||
const displayId = `V${num}`;
|
||||
const exists = await prisma.ticket.findUnique({ where: { displayId } });
|
||||
if (!exists) return displayId;
|
||||
}
|
||||
}
|
||||
|
||||
// Look up ticket by internal id or displayId
|
||||
function findByIdOrDisplay(idOrDisplay: string) {
|
||||
return prisma.ticket.findFirst({
|
||||
where: { OR: [{ id: idOrDisplay }, { displayId: idOrDisplay }] },
|
||||
});
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
overview: z.string().min(1),
|
||||
severity: z.number().int().min(1).max(5),
|
||||
categoryId: z.string().min(1),
|
||||
typeId: z.string().min(1),
|
||||
itemId: z.string().min(1),
|
||||
assigneeId: z.string().optional(),
|
||||
});
|
||||
|
||||
const updateSchema = z.object({
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
overview: z.string().min(1).optional(),
|
||||
severity: z.number().int().min(1).max(5).optional(),
|
||||
status: z.enum(['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED']).optional(),
|
||||
categoryId: z.string().min(1).optional(),
|
||||
typeId: z.string().min(1).optional(),
|
||||
itemId: z.string().min(1).optional(),
|
||||
assigneeId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
// Mount comment sub-router
|
||||
router.use('/:ticketId/comments', commentRouter);
|
||||
|
||||
// GET /api/tickets
|
||||
router.get('/', async (req: AuthRequest, res) => {
|
||||
const { status, severity, assigneeId, categoryId, typeId, itemId, search } = req.query;
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (status) where.status = status;
|
||||
if (severity) where.severity = Number(severity);
|
||||
if (assigneeId) where.assigneeId = assigneeId;
|
||||
if (itemId) where.itemId = itemId;
|
||||
else if (typeId) where.typeId = typeId;
|
||||
else if (categoryId) where.categoryId = categoryId;
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search as string, mode: 'insensitive' } },
|
||||
{ overview: { contains: search as string, mode: 'insensitive' } },
|
||||
{ displayId: { contains: search as string, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where,
|
||||
include: {
|
||||
category: true,
|
||||
type: true,
|
||||
item: true,
|
||||
assignee: { select: { id: true, username: true, displayName: true } },
|
||||
createdBy: { select: { id: true, username: true, displayName: true } },
|
||||
_count: { select: { comments: true } },
|
||||
},
|
||||
orderBy: [{ severity: 'asc' }, { createdAt: 'desc' }],
|
||||
const tickets = await ticketService.listTickets({
|
||||
status: status as string | undefined,
|
||||
severity: severity ? Number(severity) : undefined,
|
||||
assigneeId: assigneeId as string | undefined,
|
||||
categoryId: categoryId as string | undefined,
|
||||
typeId: typeId as string | undefined,
|
||||
itemId: itemId as string | undefined,
|
||||
search: search as string | undefined,
|
||||
});
|
||||
|
||||
res.json(tickets);
|
||||
});
|
||||
|
||||
// GET /api/tickets/:id
|
||||
router.get('/:id', async (req, res) => {
|
||||
const ticket = await prisma.ticket.findFirst({
|
||||
where: { OR: [{ id: req.params.id }, { displayId: req.params.id }] },
|
||||
include: ticketInclude,
|
||||
});
|
||||
if (!ticket) return res.status(404).json({ error: 'Ticket not found' });
|
||||
const ticket = await ticketService.getTicket(req.params.id);
|
||||
res.json(ticket);
|
||||
});
|
||||
|
||||
// GET /api/tickets/:id/audit
|
||||
router.get('/:id/audit', async (req, res) => {
|
||||
const ticket = await findByIdOrDisplay(req.params.id);
|
||||
if (!ticket) return res.status(404).json({ error: 'Ticket not found' });
|
||||
|
||||
const logs = await prisma.auditLog.findMany({
|
||||
where: { ticketId: ticket.id },
|
||||
include: { user: { select: { id: true, username: true, displayName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const logs = await ticketService.getTicketAudit(req.params.id);
|
||||
res.json(logs);
|
||||
});
|
||||
|
||||
// POST /api/tickets
|
||||
router.post('/', requireAgent, async (req: AuthRequest, res) => {
|
||||
const data = createSchema.parse(req.body);
|
||||
const displayId = await generateDisplayId();
|
||||
|
||||
const ticket = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.ticket.create({
|
||||
data: { displayId, ...data, createdById: req.user!.id },
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: { ticketId: created.id, userId: req.user!.id, action: 'CREATED' },
|
||||
});
|
||||
return tx.ticket.findUnique({ where: { id: created.id }, include: ticketInclude });
|
||||
});
|
||||
|
||||
const data = createTicketSchema.parse(req.body);
|
||||
const ticket = await ticketService.createTicket(data, req.user!.id);
|
||||
res.status(201).json(ticket);
|
||||
});
|
||||
|
||||
// PATCH /api/tickets/:id
|
||||
router.patch('/:id', requireAgent, async (req: AuthRequest, res) => {
|
||||
const data = updateSchema.parse(req.body);
|
||||
|
||||
// Only admins can set status to CLOSED
|
||||
if (data.status === 'CLOSED' && req.user?.role !== 'ADMIN') {
|
||||
return res.status(403).json({ error: 'Only admins can close tickets' });
|
||||
}
|
||||
|
||||
const existing = await prisma.ticket.findFirst({
|
||||
where: { OR: [{ id: req.params.id }, { displayId: req.params.id }] },
|
||||
include: {
|
||||
category: true,
|
||||
type: true,
|
||||
item: true,
|
||||
assignee: { select: { displayName: true } },
|
||||
},
|
||||
const data = updateTicketSchema.parse(req.body);
|
||||
const ticket = await ticketService.updateTicket(req.params.id, data, {
|
||||
id: req.user!.id,
|
||||
role: req.user!.role,
|
||||
});
|
||||
if (!existing) return res.status(404).json({ error: 'Ticket not found' });
|
||||
|
||||
// Build audit entries
|
||||
const auditEntries: { action: string; detail?: string }[] = [];
|
||||
|
||||
if (data.status && data.status !== existing.status) {
|
||||
auditEntries.push({
|
||||
action: 'STATUS_CHANGED',
|
||||
detail: `${STATUS_LABELS[existing.status]} → ${STATUS_LABELS[data.status]}`,
|
||||
});
|
||||
}
|
||||
|
||||
if ('assigneeId' in data && data.assigneeId !== existing.assigneeId) {
|
||||
const newAssignee = data.assigneeId
|
||||
? await prisma.user.findUnique({
|
||||
where: { id: data.assigneeId },
|
||||
select: { displayName: true },
|
||||
})
|
||||
: null;
|
||||
auditEntries.push({
|
||||
action: 'ASSIGNEE_CHANGED',
|
||||
detail: `${existing.assignee?.displayName ?? 'Unassigned'} → ${newAssignee?.displayName ?? 'Unassigned'}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.severity && data.severity !== existing.severity) {
|
||||
auditEntries.push({
|
||||
action: 'SEVERITY_CHANGED',
|
||||
detail: `SEV ${existing.severity} → SEV ${data.severity}`,
|
||||
});
|
||||
}
|
||||
|
||||
// CTI rerouting — only log if any CTI field actually changed
|
||||
const ctiChanged =
|
||||
(data.categoryId && data.categoryId !== existing.categoryId) ||
|
||||
(data.typeId && data.typeId !== existing.typeId) ||
|
||||
(data.itemId && data.itemId !== existing.itemId);
|
||||
|
||||
if (ctiChanged) {
|
||||
const [newCat, newType, newItem] = await Promise.all([
|
||||
data.categoryId && data.categoryId !== existing.categoryId
|
||||
? prisma.category.findUnique({ where: { id: data.categoryId } })
|
||||
: Promise.resolve(existing.category),
|
||||
data.typeId && data.typeId !== existing.typeId
|
||||
? prisma.type.findUnique({ where: { id: data.typeId } })
|
||||
: Promise.resolve(existing.type),
|
||||
data.itemId && data.itemId !== existing.itemId
|
||||
? prisma.item.findUnique({ where: { id: data.itemId } })
|
||||
: Promise.resolve(existing.item),
|
||||
]);
|
||||
auditEntries.push({
|
||||
action: 'REROUTED',
|
||||
detail: `${existing.category.name} › ${existing.type.name} › ${existing.item.name} → ${newCat?.name} › ${newType?.name} › ${newItem?.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.title && data.title !== existing.title) {
|
||||
auditEntries.push({ action: 'TITLE_CHANGED', detail: data.title });
|
||||
}
|
||||
|
||||
if (data.overview && data.overview !== existing.overview) {
|
||||
auditEntries.push({ action: 'OVERVIEW_CHANGED' });
|
||||
}
|
||||
|
||||
// Handle resolvedAt tracking
|
||||
const update: Record<string, unknown> = { ...data };
|
||||
if (data.status === 'RESOLVED' && existing.status !== 'RESOLVED') {
|
||||
update.resolvedAt = new Date();
|
||||
} else if (data.status && data.status !== 'RESOLVED' && existing.status === 'RESOLVED') {
|
||||
update.resolvedAt = null;
|
||||
}
|
||||
|
||||
const ticket = await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.ticket.update({
|
||||
where: { id: existing.id },
|
||||
data: update,
|
||||
});
|
||||
if (auditEntries.length > 0) {
|
||||
await tx.auditLog.createMany({
|
||||
data: auditEntries.map((e) => ({
|
||||
ticketId: existing.id,
|
||||
userId: req.user!.id,
|
||||
action: e.action,
|
||||
detail: e.detail ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return tx.ticket.findUnique({ where: { id: updated.id }, include: ticketInclude });
|
||||
});
|
||||
|
||||
res.json(ticket);
|
||||
});
|
||||
|
||||
// DELETE /api/tickets/:id — admin only
|
||||
router.delete('/:id', requireAdmin, async (req, res) => {
|
||||
const ticket = await findByIdOrDisplay(req.params.id);
|
||||
if (!ticket) return res.status(404).json({ error: 'Ticket not found' });
|
||||
await prisma.ticket.delete({ where: { id: ticket.id } });
|
||||
await ticketService.deleteTicket(req.params.id);
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,110 +1,26 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { z } from 'zod';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import prisma from '../lib/prisma';
|
||||
import { requireAdmin, AuthRequest } from '../middleware/auth';
|
||||
import { createUserSchema, updateUserSchema } from '../../../shared/schemas/user';
|
||||
import * as userService from '../services/userService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const userSelect = {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
apiKey: true,
|
||||
createdAt: true,
|
||||
} as const;
|
||||
|
||||
router.get('/', async (_req, res) => {
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { displayName: 'asc' },
|
||||
});
|
||||
res.json(users);
|
||||
res.json(await userService.listUsers());
|
||||
});
|
||||
|
||||
router.post('/', requireAdmin, async (req, res) => {
|
||||
const data = z
|
||||
.object({
|
||||
username: z.string().min(1).max(50),
|
||||
email: z.string().email(),
|
||||
displayName: z.string().min(1).max(100),
|
||||
password: z.string().min(8).optional(),
|
||||
role: z.enum(['ADMIN', 'AGENT', 'USER', 'SERVICE']).default('AGENT'),
|
||||
})
|
||||
.parse(req.body);
|
||||
|
||||
const passwordHash = data.password
|
||||
? await bcrypt.hash(data.password, 12)
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12);
|
||||
|
||||
const apiKey =
|
||||
data.role === 'SERVICE' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined;
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: data.username,
|
||||
email: data.email,
|
||||
displayName: data.displayName,
|
||||
passwordHash,
|
||||
role: data.role,
|
||||
apiKey,
|
||||
},
|
||||
select: userSelect,
|
||||
});
|
||||
|
||||
res.status(201).json(user);
|
||||
const data = createUserSchema.parse(req.body);
|
||||
res.status(201).json(await userService.createUser(data));
|
||||
});
|
||||
|
||||
router.patch('/:id', requireAdmin, async (req, res) => {
|
||||
const data = z
|
||||
.object({
|
||||
displayName: z.string().min(1).max(100).optional(),
|
||||
email: z.string().email().optional(),
|
||||
password: z.string().min(8).optional(),
|
||||
role: z.enum(['ADMIN', 'AGENT', 'USER', 'SERVICE']).optional(),
|
||||
regenerateApiKey: z.boolean().optional(),
|
||||
})
|
||||
.parse(req.body);
|
||||
|
||||
const update: Prisma.UserUpdateInput = {};
|
||||
if (data.displayName) update.displayName = data.displayName;
|
||||
if (data.email) update.email = data.email;
|
||||
if (data.password) update.passwordHash = await bcrypt.hash(data.password, 12);
|
||||
if (data.role) {
|
||||
update.role = data.role;
|
||||
if (data.role === 'SERVICE' && !update.apiKey) {
|
||||
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
|
||||
}
|
||||
}
|
||||
if (data.regenerateApiKey) {
|
||||
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: update,
|
||||
select: userSelect,
|
||||
});
|
||||
|
||||
res.json(user);
|
||||
const data = updateUserSchema.parse(req.body);
|
||||
res.json(await userService.updateUser(req.params.id, data));
|
||||
});
|
||||
|
||||
router.delete('/:id', requireAdmin, async (req: AuthRequest, res) => {
|
||||
if (req.params.id === req.user!.id) {
|
||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
}
|
||||
await prisma.user.delete({ where: { id: req.params.id } });
|
||||
await userService.deleteUser(req.params.id, req.user!.id);
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prismaMock } from '../test/setup';
|
||||
import { login } from './authService';
|
||||
import { HttpError } from '../lib/httpError';
|
||||
|
||||
describe('authService.login', () => {
|
||||
it('returns token + user for valid credentials', async () => {
|
||||
const password = 'password123';
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'u1',
|
||||
username: 'alice',
|
||||
email: 'a@x.io',
|
||||
displayName: 'Alice',
|
||||
passwordHash: await bcrypt.hash(password, 4),
|
||||
role: 'AGENT',
|
||||
apiKey: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await login({ username: 'alice', password });
|
||||
expect(result.user).toMatchObject({ id: 'u1', username: 'alice', role: 'AGENT' });
|
||||
const decoded = jwt.verify(result.token, process.env.JWT_SECRET!) as { id: string };
|
||||
expect(decoded.id).toBe('u1');
|
||||
});
|
||||
|
||||
it('rejects invalid password', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'u1',
|
||||
username: 'alice',
|
||||
email: 'a@x.io',
|
||||
displayName: 'Alice',
|
||||
passwordHash: await bcrypt.hash('correct', 4),
|
||||
role: 'AGENT',
|
||||
apiKey: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(login({ username: 'alice', password: 'wrong' })).rejects.toThrow(HttpError);
|
||||
});
|
||||
|
||||
it('rejects unknown user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||
await expect(login({ username: 'nobody', password: 'x' })).rejects.toMatchObject({
|
||||
status: 401,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects SERVICE role from password login', async () => {
|
||||
const password = 'svc-pw';
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'svc',
|
||||
username: 'goddard',
|
||||
email: 'g@x.io',
|
||||
displayName: 'Goddard',
|
||||
passwordHash: await bcrypt.hash(password, 4),
|
||||
role: 'SERVICE',
|
||||
apiKey: 'sk_xyz',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(login({ username: 'goddard', password })).rejects.toMatchObject({
|
||||
status: 401,
|
||||
message: expect.stringMatching(/API key/i),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import prisma from '../lib/prisma';
|
||||
import { HttpError } from '../lib/httpError';
|
||||
import type { LoginInput } from '../../../shared/schemas/auth';
|
||||
|
||||
export async function login({ username, password }: LoginInput) {
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
|
||||
throw new HttpError(401, 'Invalid credentials');
|
||||
}
|
||||
|
||||
if (user.role === 'SERVICE') {
|
||||
throw new HttpError(401, 'Service accounts must authenticate via API key');
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, role: user.role, username: user.username },
|
||||
process.env.JWT_SECRET!,
|
||||
{ expiresIn: '24h' },
|
||||
);
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { prismaMock } from '../test/setup';
|
||||
import { addComment, deleteComment } from './commentService';
|
||||
import { HttpError } from '../lib/httpError';
|
||||
|
||||
describe('commentService.addComment', () => {
|
||||
it('404s when ticket not found', async () => {
|
||||
prismaMock.ticket.findFirst.mockResolvedValue(null);
|
||||
await expect(addComment('V1', 'body', 'u1')).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('accepts either id or displayId and writes audit + comment', async () => {
|
||||
prismaMock.ticket.findFirst.mockResolvedValue({
|
||||
id: 'tid',
|
||||
displayId: 'V111',
|
||||
title: 't',
|
||||
overview: 'o',
|
||||
severity: 3,
|
||||
status: 'OPEN',
|
||||
categoryId: 'c',
|
||||
typeId: 'ty',
|
||||
itemId: 'i',
|
||||
assigneeId: null,
|
||||
createdById: 'u1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
resolvedAt: null,
|
||||
});
|
||||
prismaMock.comment.create.mockResolvedValue({
|
||||
id: 'cid',
|
||||
body: 'hi',
|
||||
ticketId: 'tid',
|
||||
authorId: 'u1',
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await addComment('V111', 'hi', 'u1');
|
||||
expect(prismaMock.comment.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ ticketId: 'tid', body: 'hi' }) }),
|
||||
);
|
||||
expect(prismaMock.auditLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ action: 'COMMENT_ADDED', detail: 'hi' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commentService.deleteComment', () => {
|
||||
const baseComment = {
|
||||
id: 'cid',
|
||||
body: 'x',
|
||||
ticketId: 'tid',
|
||||
authorId: 'author1',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
it('rejects non-author non-admin with 403', async () => {
|
||||
prismaMock.comment.findUnique.mockResolvedValue(baseComment);
|
||||
await expect(
|
||||
deleteComment('cid', { id: 'other', role: 'AGENT' }),
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('allows the author to delete', async () => {
|
||||
prismaMock.comment.findUnique.mockResolvedValue(baseComment);
|
||||
await expect(deleteComment('cid', { id: 'author1', role: 'AGENT' })).resolves.toBeUndefined();
|
||||
expect(prismaMock.comment.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows admin to delete', async () => {
|
||||
prismaMock.comment.findUnique.mockResolvedValue(baseComment);
|
||||
await expect(deleteComment('cid', { id: 'other', role: 'ADMIN' })).resolves.toBeUndefined();
|
||||
expect(prismaMock.comment.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s when comment missing', async () => {
|
||||
prismaMock.comment.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
deleteComment('missing', { id: 'u', role: 'ADMIN' }),
|
||||
).rejects.toThrow(HttpError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import prisma from '../lib/prisma';
|
||||
import { HttpError } from '../lib/httpError';
|
||||
|
||||
export async function addComment(ticketIdOrDisplay: string, body: string, actorId: string) {
|
||||
const ticket = await prisma.ticket.findFirst({
|
||||
where: { OR: [{ id: ticketIdOrDisplay }, { displayId: ticketIdOrDisplay }] },
|
||||
});
|
||||
if (!ticket) throw new HttpError(404, 'Ticket not found');
|
||||
|
||||
const [comment] = await prisma.$transaction([
|
||||
prisma.comment.create({
|
||||
data: { body, ticketId: ticket.id, authorId: actorId },
|
||||
include: { author: { select: { id: true, username: true, displayName: true } } },
|
||||
}),
|
||||
prisma.auditLog.create({
|
||||
data: { ticketId: ticket.id, userId: actorId, action: 'COMMENT_ADDED', detail: body },
|
||||
}),
|
||||
]);
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
export async function deleteComment(
|
||||
commentId: string,
|
||||
actor: { id: string; role: string },
|
||||
) {
|
||||
const comment = await prisma.comment.findUnique({ where: { id: commentId } });
|
||||
if (!comment) throw new HttpError(404, 'Comment not found');
|
||||
|
||||
if (comment.authorId !== actor.id && actor.role !== 'ADMIN') {
|
||||
throw new HttpError(403, 'Not allowed');
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.comment.delete({ where: { id: commentId } }),
|
||||
prisma.auditLog.create({
|
||||
data: {
|
||||
ticketId: comment.ticketId,
|
||||
userId: actor.id,
|
||||
action: 'COMMENT_DELETED',
|
||||
detail: comment.body,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import prisma from '../lib/prisma';
|
||||
|
||||
// ── Categories ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function listCategories() {
|
||||
return prisma.category.findMany({ orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
export function createCategory(name: string) {
|
||||
return prisma.category.create({ data: { name } });
|
||||
}
|
||||
|
||||
export function updateCategory(id: string, name: string) {
|
||||
return prisma.category.update({ where: { id }, data: { name } });
|
||||
}
|
||||
|
||||
export async function deleteCategory(id: string) {
|
||||
await prisma.category.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function listTypes(categoryId?: string) {
|
||||
return prisma.type.findMany({
|
||||
where: categoryId ? { categoryId } : undefined,
|
||||
include: { category: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
export function createType(name: string, categoryId: string) {
|
||||
return prisma.type.create({
|
||||
data: { name, categoryId },
|
||||
include: { category: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function updateType(id: string, name: string) {
|
||||
return prisma.type.update({
|
||||
where: { id },
|
||||
data: { name },
|
||||
include: { category: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteType(id: string) {
|
||||
await prisma.type.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// ── Items ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function listItems(typeId?: string) {
|
||||
return prisma.item.findMany({
|
||||
where: typeId ? { typeId } : undefined,
|
||||
include: { type: { include: { category: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
export function createItem(name: string, typeId: string) {
|
||||
return prisma.item.create({
|
||||
data: { name, typeId },
|
||||
include: { type: { include: { category: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
export function updateItem(id: string, name: string) {
|
||||
return prisma.item.update({
|
||||
where: { id },
|
||||
data: { name },
|
||||
include: { type: { include: { category: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteItem(id: string) {
|
||||
await prisma.item.delete({ where: { id } });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Stub — filled in Phase 2 (email, webhooks, in-app).
|
||||
// Routes/services call these hooks; implementations land later.
|
||||
|
||||
export async function notifyAssigned(_ticketId: string, _assigneeId: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
export async function notifyStatusChanged(
|
||||
_ticketId: string,
|
||||
_from: string,
|
||||
_to: string,
|
||||
): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
export async function notifyCommentAdded(_ticketId: string, _commentId: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
export async function notifyMention(_ticketId: string, _mentionedUserId: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Stub — Phase 2 replaces this with Postgres FTS (tsvector + plainto_tsquery).
|
||||
// For now this is a thin wrapper; listTickets already supports a `search` filter.
|
||||
|
||||
import { listTickets, type TicketFilters } from './ticketService';
|
||||
|
||||
export function searchTickets(query: string, filters: Omit<TicketFilters, 'search'> = {}) {
|
||||
return listTickets({ ...filters, search: query });
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { prismaMock } from '../test/setup';
|
||||
import { createTicket, updateTicket, closeStale } from './ticketService';
|
||||
|
||||
const existing = {
|
||||
id: 'tid',
|
||||
displayId: 'V100000000',
|
||||
title: 'Old title',
|
||||
overview: 'overview',
|
||||
severity: 3,
|
||||
status: 'OPEN' as const,
|
||||
categoryId: 'c1',
|
||||
typeId: 't1',
|
||||
itemId: 'i1',
|
||||
assigneeId: null,
|
||||
createdById: 'u1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
resolvedAt: null,
|
||||
category: { id: 'c1', name: 'Cat' },
|
||||
type: { id: 't1', name: 'Type' },
|
||||
item: { id: 'i1', name: 'Item' },
|
||||
assignee: null,
|
||||
};
|
||||
|
||||
describe('ticketService.createTicket', () => {
|
||||
it('generates a displayId and writes CREATED audit', async () => {
|
||||
// First call is generateDisplayId's uniqueness probe — must be null
|
||||
prismaMock.ticket.findUnique.mockResolvedValueOnce(null);
|
||||
prismaMock.ticket.create.mockResolvedValue({ ...existing });
|
||||
// Second call is the post-create read-with-include
|
||||
prismaMock.ticket.findUnique.mockResolvedValueOnce({ ...existing });
|
||||
|
||||
await createTicket(
|
||||
{
|
||||
title: 'New',
|
||||
overview: 'body',
|
||||
severity: 2,
|
||||
categoryId: 'c1',
|
||||
typeId: 't1',
|
||||
itemId: 'i1',
|
||||
},
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(prismaMock.ticket.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
title: 'New',
|
||||
createdById: 'u1',
|
||||
displayId: expect.stringMatching(/^V\d{9}$/),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(prismaMock.auditLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ action: 'CREATED' }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ticketService.updateTicket', () => {
|
||||
it('rejects non-admin trying to CLOSE', async () => {
|
||||
await expect(
|
||||
updateTicket('tid', { status: 'CLOSED' }, { id: 'u1', role: 'AGENT' }),
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('writes STATUS_CHANGED + SEVERITY_CHANGED audits; resolvedAt set on RESOLVED', async () => {
|
||||
prismaMock.ticket.findFirst.mockResolvedValue(existing);
|
||||
prismaMock.ticket.update.mockResolvedValue({
|
||||
...existing,
|
||||
status: 'RESOLVED' as const,
|
||||
severity: 1,
|
||||
});
|
||||
prismaMock.ticket.findUnique.mockResolvedValue({ ...existing, status: 'RESOLVED' as const });
|
||||
|
||||
await updateTicket(
|
||||
'tid',
|
||||
{ status: 'RESOLVED', severity: 1 },
|
||||
{ id: 'u2', role: 'AGENT' },
|
||||
);
|
||||
|
||||
expect(prismaMock.ticket.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'RESOLVED', resolvedAt: expect.any(Date) }),
|
||||
}),
|
||||
);
|
||||
expect(prismaMock.auditLog.createMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ action: 'STATUS_CHANGED' }),
|
||||
expect.objectContaining({ action: 'SEVERITY_CHANGED' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears resolvedAt when moving away from RESOLVED', async () => {
|
||||
prismaMock.ticket.findFirst.mockResolvedValue({ ...existing, status: 'RESOLVED' as const });
|
||||
prismaMock.ticket.update.mockResolvedValue({ ...existing, status: 'IN_PROGRESS' as const });
|
||||
prismaMock.ticket.findUnique.mockResolvedValue({ ...existing, status: 'IN_PROGRESS' as const });
|
||||
|
||||
await updateTicket(
|
||||
'tid',
|
||||
{ status: 'IN_PROGRESS' },
|
||||
{ id: 'u2', role: 'AGENT' },
|
||||
);
|
||||
|
||||
expect(prismaMock.ticket.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ resolvedAt: null }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('404s when ticket missing', async () => {
|
||||
prismaMock.ticket.findFirst.mockResolvedValue(null);
|
||||
await expect(
|
||||
updateTicket('missing', { title: 'x' }, { id: 'u1', role: 'ADMIN' }),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ticketService.closeStale', () => {
|
||||
it('closes RESOLVED tickets older than cutoff and returns count', async () => {
|
||||
prismaMock.ticket.updateMany.mockResolvedValue({ count: 3 });
|
||||
const count = await closeStale(14);
|
||||
expect(count).toBe(3);
|
||||
expect(prismaMock.ticket.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ status: 'RESOLVED' }),
|
||||
data: { status: 'CLOSED' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import prisma from '../lib/prisma';
|
||||
import { HttpError } from '../lib/httpError';
|
||||
import type {
|
||||
CreateTicketInput,
|
||||
UpdateTicketInput,
|
||||
} from '../../../shared/schemas/ticket';
|
||||
|
||||
export const ticketInclude = {
|
||||
category: true,
|
||||
type: true,
|
||||
item: true,
|
||||
assignee: { select: { id: true, username: true, displayName: true } },
|
||||
createdBy: { select: { id: true, username: true, displayName: true } },
|
||||
comments: {
|
||||
include: { author: { select: { id: true, username: true, displayName: true } } },
|
||||
orderBy: { createdAt: 'asc' as const },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const ticketListInclude = {
|
||||
category: true,
|
||||
type: true,
|
||||
item: true,
|
||||
assignee: { select: { id: true, username: true, displayName: true } },
|
||||
createdBy: { select: { id: true, username: true, displayName: true } },
|
||||
_count: { select: { comments: true } },
|
||||
} as const;
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
OPEN: 'Open',
|
||||
IN_PROGRESS: 'In Progress',
|
||||
RESOLVED: 'Resolved',
|
||||
CLOSED: 'Closed',
|
||||
};
|
||||
|
||||
export type TicketFilters = {
|
||||
status?: string;
|
||||
severity?: number;
|
||||
assigneeId?: string;
|
||||
categoryId?: string;
|
||||
typeId?: string;
|
||||
itemId?: string;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
async function generateDisplayId(): Promise<string> {
|
||||
while (true) {
|
||||
const num = Math.floor(Math.random() * 900_000_000) + 100_000_000;
|
||||
const displayId = `V${num}`;
|
||||
const exists = await prisma.ticket.findUnique({ where: { displayId } });
|
||||
if (!exists) return displayId;
|
||||
}
|
||||
}
|
||||
|
||||
export function findByIdOrDisplay(idOrDisplay: string) {
|
||||
return prisma.ticket.findFirst({
|
||||
where: { OR: [{ id: idOrDisplay }, { displayId: idOrDisplay }] },
|
||||
});
|
||||
}
|
||||
|
||||
export async function listTickets(filters: TicketFilters) {
|
||||
const where: Prisma.TicketWhereInput = {};
|
||||
if (filters.status) where.status = filters.status as Prisma.TicketWhereInput['status'];
|
||||
if (filters.severity) where.severity = filters.severity;
|
||||
if (filters.assigneeId) where.assigneeId = filters.assigneeId;
|
||||
if (filters.itemId) where.itemId = filters.itemId;
|
||||
else if (filters.typeId) where.typeId = filters.typeId;
|
||||
else if (filters.categoryId) where.categoryId = filters.categoryId;
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ title: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ overview: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ displayId: { contains: filters.search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
return prisma.ticket.findMany({
|
||||
where,
|
||||
include: ticketListInclude,
|
||||
orderBy: [{ severity: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTicket(idOrDisplay: string) {
|
||||
const ticket = await prisma.ticket.findFirst({
|
||||
where: { OR: [{ id: idOrDisplay }, { displayId: idOrDisplay }] },
|
||||
include: ticketInclude,
|
||||
});
|
||||
if (!ticket) throw new HttpError(404, 'Ticket not found');
|
||||
return ticket;
|
||||
}
|
||||
|
||||
export async function getTicketAudit(idOrDisplay: string) {
|
||||
const ticket = await findByIdOrDisplay(idOrDisplay);
|
||||
if (!ticket) throw new HttpError(404, 'Ticket not found');
|
||||
|
||||
return prisma.auditLog.findMany({
|
||||
where: { ticketId: ticket.id },
|
||||
include: { user: { select: { id: true, username: true, displayName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTicket(data: CreateTicketInput, actorId: string) {
|
||||
const displayId = await generateDisplayId();
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const created = await tx.ticket.create({
|
||||
data: { displayId, ...data, createdById: actorId },
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: { ticketId: created.id, userId: actorId, action: 'CREATED' },
|
||||
});
|
||||
return tx.ticket.findUnique({ where: { id: created.id }, include: ticketInclude });
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTicket(
|
||||
idOrDisplay: string,
|
||||
data: UpdateTicketInput,
|
||||
actor: { id: string; role: string },
|
||||
) {
|
||||
if (data.status === 'CLOSED' && actor.role !== 'ADMIN') {
|
||||
throw new HttpError(403, 'Only admins can close tickets');
|
||||
}
|
||||
|
||||
const existing = await prisma.ticket.findFirst({
|
||||
where: { OR: [{ id: idOrDisplay }, { displayId: idOrDisplay }] },
|
||||
include: {
|
||||
category: true,
|
||||
type: true,
|
||||
item: true,
|
||||
assignee: { select: { displayName: true } },
|
||||
},
|
||||
});
|
||||
if (!existing) throw new HttpError(404, 'Ticket not found');
|
||||
|
||||
const auditEntries: { action: string; detail?: string }[] = [];
|
||||
|
||||
if (data.status && data.status !== existing.status) {
|
||||
auditEntries.push({
|
||||
action: 'STATUS_CHANGED',
|
||||
detail: `${STATUS_LABELS[existing.status]} → ${STATUS_LABELS[data.status]}`,
|
||||
});
|
||||
}
|
||||
|
||||
if ('assigneeId' in data && data.assigneeId !== existing.assigneeId) {
|
||||
const newAssignee = data.assigneeId
|
||||
? await prisma.user.findUnique({
|
||||
where: { id: data.assigneeId },
|
||||
select: { displayName: true },
|
||||
})
|
||||
: null;
|
||||
auditEntries.push({
|
||||
action: 'ASSIGNEE_CHANGED',
|
||||
detail: `${existing.assignee?.displayName ?? 'Unassigned'} → ${newAssignee?.displayName ?? 'Unassigned'}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.severity && data.severity !== existing.severity) {
|
||||
auditEntries.push({
|
||||
action: 'SEVERITY_CHANGED',
|
||||
detail: `SEV ${existing.severity} → SEV ${data.severity}`,
|
||||
});
|
||||
}
|
||||
|
||||
const ctiChanged =
|
||||
(data.categoryId && data.categoryId !== existing.categoryId) ||
|
||||
(data.typeId && data.typeId !== existing.typeId) ||
|
||||
(data.itemId && data.itemId !== existing.itemId);
|
||||
|
||||
if (ctiChanged) {
|
||||
const [newCat, newType, newItem] = await Promise.all([
|
||||
data.categoryId && data.categoryId !== existing.categoryId
|
||||
? prisma.category.findUnique({ where: { id: data.categoryId } })
|
||||
: Promise.resolve(existing.category),
|
||||
data.typeId && data.typeId !== existing.typeId
|
||||
? prisma.type.findUnique({ where: { id: data.typeId } })
|
||||
: Promise.resolve(existing.type),
|
||||
data.itemId && data.itemId !== existing.itemId
|
||||
? prisma.item.findUnique({ where: { id: data.itemId } })
|
||||
: Promise.resolve(existing.item),
|
||||
]);
|
||||
auditEntries.push({
|
||||
action: 'REROUTED',
|
||||
detail: `${existing.category.name} › ${existing.type.name} › ${existing.item.name} → ${newCat?.name} › ${newType?.name} › ${newItem?.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.title && data.title !== existing.title) {
|
||||
auditEntries.push({ action: 'TITLE_CHANGED', detail: data.title });
|
||||
}
|
||||
|
||||
if (data.overview && data.overview !== existing.overview) {
|
||||
auditEntries.push({ action: 'OVERVIEW_CHANGED' });
|
||||
}
|
||||
|
||||
const update: Prisma.TicketUpdateInput = { ...data } as Prisma.TicketUpdateInput;
|
||||
if (data.status === 'RESOLVED' && existing.status !== 'RESOLVED') {
|
||||
update.resolvedAt = new Date();
|
||||
} else if (data.status && data.status !== 'RESOLVED' && existing.status === 'RESOLVED') {
|
||||
update.resolvedAt = null;
|
||||
}
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.ticket.update({
|
||||
where: { id: existing.id },
|
||||
data: update,
|
||||
});
|
||||
if (auditEntries.length > 0) {
|
||||
await tx.auditLog.createMany({
|
||||
data: auditEntries.map((e) => ({
|
||||
ticketId: existing.id,
|
||||
userId: actor.id,
|
||||
action: e.action,
|
||||
detail: e.detail ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return tx.ticket.findUnique({ where: { id: updated.id }, include: ticketInclude });
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTicket(idOrDisplay: string) {
|
||||
const ticket = await findByIdOrDisplay(idOrDisplay);
|
||||
if (!ticket) throw new HttpError(404, 'Ticket not found');
|
||||
await prisma.ticket.delete({ where: { id: ticket.id } });
|
||||
}
|
||||
|
||||
export async function closeStale(olderThanDays = 14): Promise<number> {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - olderThanDays);
|
||||
|
||||
const result = await prisma.ticket.updateMany({
|
||||
where: { status: 'RESOLVED', resolvedAt: { lte: cutoff } },
|
||||
data: { status: 'CLOSED' },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { prismaMock } from '../test/setup';
|
||||
import { createUser, deleteUser } from './userService';
|
||||
|
||||
const stubUser = {
|
||||
id: 'uid',
|
||||
username: 'x',
|
||||
email: 'x@x',
|
||||
displayName: 'X',
|
||||
role: 'AGENT' as const,
|
||||
passwordHash: '',
|
||||
apiKey: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
describe('userService.createUser', () => {
|
||||
it('hashes the password and omits apiKey for non-SERVICE roles', async () => {
|
||||
prismaMock.user.create.mockResolvedValue(stubUser);
|
||||
|
||||
await createUser({
|
||||
username: 'bob',
|
||||
email: 'b@x.io',
|
||||
displayName: 'Bob',
|
||||
password: 'hunter2!',
|
||||
role: 'AGENT',
|
||||
});
|
||||
|
||||
const call = prismaMock.user.create.mock.calls[0][0] as { data: Record<string, unknown> };
|
||||
expect(call.data.passwordHash).not.toBe('hunter2!');
|
||||
expect(await bcrypt.compare('hunter2!', call.data.passwordHash as string)).toBe(true);
|
||||
expect(call.data.apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('assigns an apiKey for SERVICE role', async () => {
|
||||
prismaMock.user.create.mockResolvedValue({ ...stubUser, role: 'SERVICE' });
|
||||
|
||||
await createUser({
|
||||
username: 'svc',
|
||||
email: 's@x.io',
|
||||
displayName: 'Svc',
|
||||
role: 'SERVICE',
|
||||
});
|
||||
|
||||
const call = prismaMock.user.create.mock.calls[0][0] as { data: Record<string, unknown> };
|
||||
expect(call.data.apiKey).toMatch(/^sk_[a-f0-9]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('userService.deleteUser', () => {
|
||||
it('prevents self-deletion', async () => {
|
||||
await expect(deleteUser('same', 'same')).rejects.toMatchObject({ status: 400 });
|
||||
expect(prismaMock.user.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes a different user', async () => {
|
||||
await deleteUser('other', 'me');
|
||||
expect(prismaMock.user.delete).toHaveBeenCalledWith({ where: { id: 'other' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import prisma from '../lib/prisma';
|
||||
import { HttpError } from '../lib/httpError';
|
||||
import type { CreateUserInput, UpdateUserInput } from '../../../shared/schemas/user';
|
||||
|
||||
const userSelect = {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
apiKey: true,
|
||||
createdAt: true,
|
||||
} as const;
|
||||
|
||||
const userListSelect = {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
} as const;
|
||||
|
||||
export function listUsers() {
|
||||
return prisma.user.findMany({
|
||||
select: userListSelect,
|
||||
orderBy: { displayName: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCurrentUser(id: string) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: userListSelect,
|
||||
});
|
||||
if (!user) throw new HttpError(404, 'User not found');
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function createUser(data: CreateUserInput) {
|
||||
const passwordHash = data.password
|
||||
? await bcrypt.hash(data.password, 12)
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12);
|
||||
|
||||
const apiKey =
|
||||
data.role === 'SERVICE' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined;
|
||||
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
username: data.username,
|
||||
email: data.email,
|
||||
displayName: data.displayName,
|
||||
passwordHash,
|
||||
role: data.role,
|
||||
apiKey,
|
||||
},
|
||||
select: userSelect,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateUser(id: string, data: UpdateUserInput) {
|
||||
const update: Prisma.UserUpdateInput = {};
|
||||
if (data.displayName) update.displayName = data.displayName;
|
||||
if (data.email) update.email = data.email;
|
||||
if (data.password) update.passwordHash = await bcrypt.hash(data.password, 12);
|
||||
if (data.role) {
|
||||
update.role = data.role;
|
||||
if (data.role === 'SERVICE' && !update.apiKey) {
|
||||
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
|
||||
}
|
||||
}
|
||||
if (data.regenerateApiKey) {
|
||||
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
|
||||
}
|
||||
|
||||
return prisma.user.update({ where: { id }, data: update, select: userSelect });
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string, actorId: string) {
|
||||
if (id === actorId) {
|
||||
throw new HttpError(400, 'Cannot delete your own account');
|
||||
}
|
||||
await prisma.user.delete({ where: { id } });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, vi } from 'vitest';
|
||||
import { mockDeep, mockReset, DeepMockProxy } from 'vitest-mock-extended';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
|
||||
vi.mock('../lib/prisma', () => ({
|
||||
__esModule: true,
|
||||
default: mockDeep<PrismaClient>(),
|
||||
}));
|
||||
|
||||
// Provide a JWT secret so authService can sign tokens in tests
|
||||
process.env.JWT_SECRET ??= 'test-secret';
|
||||
|
||||
import prisma from '../lib/prisma';
|
||||
|
||||
export const prismaMock = prisma as unknown as DeepMockProxy<PrismaClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockReset(prismaMock);
|
||||
|
||||
// $transaction([promises]) — resolve each promise
|
||||
prismaMock.$transaction.mockImplementation(async (arg: unknown) => {
|
||||
if (typeof arg === 'function') {
|
||||
return (arg as (tx: unknown) => unknown)(prismaMock);
|
||||
}
|
||||
if (Array.isArray(arg)) {
|
||||
return Promise.all(arg);
|
||||
}
|
||||
return arg;
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,13 @@
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"rootDir": "..",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"include": ["src/**/*", "../shared/**/*"],
|
||||
"exclude": ["node_modules", "dist", "../client", "../node_modules"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.ts'],
|
||||
setupFiles: ['src/test/setup.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user