Add ESLint + Prettier + EditorConfig tooling at repo root

v1.0 Phase 1.1 — repo-wide lint/format baseline.

- eslint.config.mjs (flat config) lints server, client, shared
- .prettierrc.json, .prettierignore, .editorconfig, .nvmrc
- Root package.json holds shared devDeps; per-package scripts keep
  their typecheck + test runners
- Fix 7 lint issues surfaced by the baseline run:
  - TicketDetail.tsx: replace ternary-with-side-effects with if/else
  - admin/Users.tsx: escape apostrophe in JSX
  - errorHandler.ts: typed err as unknown with ErrorLike refinement
  - users.ts: Prisma.UserUpdateInput instead of Record<string, any>
  - seed.ts: drop unused goddard binding
- Run prettier across tracked sources for a clean formatting baseline
This commit is contained in:
2026-04-18 14:47:34 -04:00
parent 2a6090e473
commit 27d2ab0f0d
48 changed files with 14460 additions and 1096 deletions
+26 -26
View File
@@ -1,41 +1,41 @@
import 'express-async-errors'
import express from 'express'
import cors from 'cors'
import dotenv from 'dotenv'
import 'express-async-errors';
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import authRoutes from './routes/auth'
import ticketRoutes from './routes/tickets'
import ctiRoutes from './routes/cti'
import userRoutes from './routes/users'
import { authenticate } from './middleware/auth'
import { errorHandler } from './middleware/errorHandler'
import { startAutoCloseJob } from './jobs/autoClose'
import authRoutes from './routes/auth';
import ticketRoutes from './routes/tickets';
import ctiRoutes from './routes/cti';
import userRoutes from './routes/users';
import { authenticate } from './middleware/auth';
import { errorHandler } from './middleware/errorHandler';
import { startAutoCloseJob } from './jobs/autoClose';
dotenv.config()
dotenv.config();
if (!process.env.JWT_SECRET) {
console.error('FATAL: JWT_SECRET is not set')
process.exit(1)
console.error('FATAL: JWT_SECRET is not set');
process.exit(1);
}
const app = express()
const app = express();
app.use(cors({ origin: process.env.CLIENT_URL || 'http://localhost:5173' }))
app.use(express.json())
app.use(cors({ origin: process.env.CLIENT_URL || 'http://localhost:5173' }));
app.use(express.json());
// Public
app.use('/api/auth', authRoutes)
app.use('/api/auth', authRoutes);
// Protected
app.use('/api/tickets', authenticate, ticketRoutes)
app.use('/api/cti', authenticate, ctiRoutes)
app.use('/api/users', authenticate, userRoutes)
app.use('/api/tickets', authenticate, ticketRoutes);
app.use('/api/cti', authenticate, ctiRoutes);
app.use('/api/users', authenticate, userRoutes);
app.use(errorHandler)
app.use(errorHandler);
startAutoCloseJob()
startAutoCloseJob();
const PORT = Number(process.env.PORT) || 3000
const PORT = Number(process.env.PORT) || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})
console.log(`Server running on port ${PORT}`);
});
+8 -8
View File
@@ -1,11 +1,11 @@
import cron from 'node-cron'
import prisma from '../lib/prisma'
import cron from 'node-cron';
import prisma from '../lib/prisma';
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 twoWeeksAgo = new Date();
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14);
const result = await prisma.ticket.updateMany({
where: {
@@ -13,12 +13,12 @@ export function startAutoCloseJob() {
resolvedAt: { lte: twoWeeksAgo },
},
data: { status: 'CLOSED' },
})
});
if (result.count > 0) {
console.log(`[AutoClose] Closed ${result.count} ticket(s) after 2-week resolution period`)
console.log(`[AutoClose] Closed ${result.count} ticket(s) after 2-week resolution period`);
}
})
});
console.log('[AutoClose] Job scheduled — runs every hour')
console.log('[AutoClose] Job scheduled — runs every hour');
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { PrismaClient } from '@prisma/client'
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient()
const prisma = new PrismaClient();
export default prisma
export default prisma;
+32 -44
View File
@@ -1,69 +1,57 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import prisma from '../lib/prisma'
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import prisma from '../lib/prisma';
export interface AuthRequest extends Request {
user?: {
id: string
role: string
username: string
}
id: string;
role: string;
username: string;
};
}
export const authenticate = async (
req: AuthRequest,
res: Response,
next: NextFunction
) => {
const apiKey = req.headers['x-api-key'] as string | undefined
export const authenticate = async (req: AuthRequest, res: Response, next: NextFunction) => {
const apiKey = req.headers['x-api-key'] as string | undefined;
if (apiKey) {
const user = await prisma.user.findUnique({ where: { apiKey } })
const user = await prisma.user.findUnique({ where: { apiKey } });
if (!user || user.role !== 'SERVICE') {
return res.status(401).json({ error: 'Invalid API key' })
return res.status(401).json({ error: 'Invalid API key' });
}
req.user = { id: user.id, role: user.role, username: user.username }
return next()
req.user = { id: user.id, role: user.role, username: user.username };
return next();
}
const authHeader = req.headers.authorization
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized' })
return res.status(401).json({ error: 'Unauthorized' });
}
const token = authHeader.split(' ')[1]
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
id: string
role: string
username: string
}
req.user = payload
next()
id: string;
role: string;
username: string;
};
req.user = payload;
next();
} catch {
return res.status(401).json({ error: 'Invalid or expired token' })
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
};
export const requireAdmin = (
req: AuthRequest,
res: Response,
next: NextFunction
) => {
export const requireAdmin = (req: AuthRequest, res: Response, next: NextFunction) => {
if (req.user?.role !== 'ADMIN') {
return res.status(403).json({ error: 'Admin access required' })
return res.status(403).json({ error: 'Admin access required' });
}
next()
}
next();
};
// Blocks USER role — allows ADMIN, AGENT, SERVICE
export const requireAgent = (
req: AuthRequest,
res: Response,
next: NextFunction
) => {
export const requireAgent = (req: AuthRequest, res: Response, next: NextFunction) => {
if (req.user?.role === 'USER') {
return res.status(403).json({ error: 'Insufficient permissions' })
return res.status(403).json({ error: 'Insufficient permissions' });
}
next()
}
next();
};
+21 -19
View File
@@ -1,29 +1,31 @@
import { Request, Response, NextFunction } from 'express'
import { ZodError } from 'zod'
import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';
export function errorHandler(
err: any,
req: Request,
res: Response,
next: NextFunction
) {
console.error(err)
type ErrorLike = {
code?: string;
status?: number;
statusCode?: number;
message?: string;
};
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
console.error(err);
if (err instanceof ZodError) {
return res.status(400).json({ error: 'Validation error', details: err.flatten() })
return res.status(400).json({ error: 'Validation error', details: err.flatten() });
}
// Prisma unique constraint violation
if (err.code === 'P2002') {
return res.status(409).json({ error: 'A record with that value already exists' })
const e = (err ?? {}) as ErrorLike;
if (e.code === 'P2002') {
return res.status(409).json({ error: 'A record with that value already exists' });
}
// Prisma record not found
if (err.code === 'P2025') {
return res.status(404).json({ error: 'Record not found' })
if (e.code === 'P2025') {
return res.status(404).json({ error: 'Record not found' });
}
const status = err.status || err.statusCode || 500
const message = err.message || 'Internal server error'
res.status(status).json({ error: message })
const status = e.status || e.statusCode || 500;
const message = e.message || 'Internal server error';
res.status(status).json({ error: message });
}
+21 -21
View File
@@ -1,34 +1,34 @@
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 { 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';
const router = Router()
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 { username, password } = loginSchema.parse(req.body);
const user = await prisma.user.findUnique({ where: { username } })
const user = await prisma.user.findUnique({ where: { username } });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: 'Invalid credentials' })
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' })
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' }
)
{ expiresIn: '24h' },
);
res.json({
token,
@@ -39,16 +39,16 @@ router.post('/login', async (req, res) => {
email: user.email,
role: user.role,
},
})
})
});
});
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' })
res.json(user)
})
});
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
});
export default router
export default router;
+20 -20
View File
@@ -1,22 +1,22 @@
import { Router } from 'express'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { AuthRequest } from '../middleware/auth'
import { Router } from 'express';
import { z } from 'zod';
import prisma from '../lib/prisma';
import { AuthRequest } from '../middleware/auth';
const router = Router({ mergeParams: true })
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 { 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' })
});
if (!ticket) return res.status(404).json({ error: 'Ticket not found' });
const [comment] = await prisma.$transaction([
prisma.comment.create({
@@ -26,19 +26,19 @@ router.post('/', async (req: AuthRequest, res) => {
prisma.auditLog.create({
data: { ticketId: ticket.id, userId: req.user!.id, action: 'COMMENT_ADDED', detail: body },
}),
])
]);
res.status(201).json(comment)
})
res.status(201).json(comment);
});
router.delete('/:commentId', async (req: AuthRequest, res) => {
const comment = await prisma.comment.findUnique({
where: { id: req.params.commentId },
})
if (!comment) return res.status(404).json({ error: 'Comment not found' })
});
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' })
return res.status(403).json({ error: 'Not allowed' });
}
await prisma.$transaction([
@@ -51,9 +51,9 @@ router.delete('/:commentId', async (req: AuthRequest, res) => {
detail: comment.body,
},
}),
])
]);
res.status(204).send()
})
res.status(204).send();
});
export default router
export default router;
+51 -51
View File
@@ -1,113 +1,113 @@
import { Router } from 'express'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { requireAdmin } from '../middleware/auth'
import { Router } from 'express';
import { z } from 'zod';
import prisma from '../lib/prisma';
import { requireAdmin } from '../middleware/auth';
const router = Router()
const router = Router();
const nameSchema = z.object({ name: z.string().min(1).max(100) })
const nameSchema = z.object({ name: z.string().min(1).max(100) });
// ── Categories ────────────────────────────────────────────────────────────────
router.get('/categories', async (_req, res) => {
const categories = await prisma.category.findMany({ orderBy: { name: 'asc' } })
res.json(categories)
})
const categories = await prisma.category.findMany({ orderBy: { name: 'asc' } });
res.json(categories);
});
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)
})
const { name } = nameSchema.parse(req.body);
const category = await prisma.category.create({ data: { name } });
res.status(201).json(category);
});
router.put('/categories/:id', requireAdmin, async (req, res) => {
const { name } = nameSchema.parse(req.body)
const { name } = nameSchema.parse(req.body);
const category = await prisma.category.update({
where: { id: req.params.id },
data: { name },
})
res.json(category)
})
});
res.json(category);
});
router.delete('/categories/:id', requireAdmin, async (req, res) => {
await prisma.category.delete({ where: { id: req.params.id } })
res.status(204).send()
})
await prisma.category.delete({ where: { id: req.params.id } });
res.status(204).send();
});
// ── Types ─────────────────────────────────────────────────────────────────────
router.get('/types', async (req, res) => {
const { categoryId } = req.query
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(types);
});
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)
.parse(req.body);
const type = await prisma.type.create({
data: { name, categoryId },
include: { category: true },
})
res.status(201).json(type)
})
});
res.status(201).json(type);
});
router.put('/types/:id', requireAdmin, async (req, res) => {
const { name } = nameSchema.parse(req.body)
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(type);
});
router.delete('/types/:id', requireAdmin, async (req, res) => {
await prisma.type.delete({ where: { id: req.params.id } })
res.status(204).send()
})
await prisma.type.delete({ where: { id: req.params.id } });
res.status(204).send();
});
// ── Items ─────────────────────────────────────────────────────────────────────
router.get('/items', async (req, res) => {
const { typeId } = req.query
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(items);
});
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)
.parse(req.body);
const item = await prisma.item.create({
data: { name, typeId },
include: { type: { include: { category: true } } },
})
res.status(201).json(item)
})
});
res.status(201).json(item);
});
router.put('/items/:id', requireAdmin, async (req, res) => {
const { name } = nameSchema.parse(req.body)
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(item);
});
router.delete('/items/:id', requireAdmin, async (req, res) => {
await prisma.item.delete({ where: { id: req.params.id } })
res.status(204).send()
})
await prisma.item.delete({ where: { id: req.params.id } });
res.status(204).send();
});
export default router
export default router;
+74 -74
View File
@@ -1,10 +1,10 @@
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 { Router } from 'express';
import { z } from 'zod';
import prisma from '../lib/prisma';
import { requireAdmin, requireAgent, AuthRequest } from '../middleware/auth';
import commentRouter from './comments';
const router = Router()
const router = Router();
const ticketInclude = {
category: true,
@@ -16,21 +16,21 @@ const ticketInclude = {
include: { author: { select: { id: true, username: true, displayName: true } } },
orderBy: { createdAt: 'asc' as const },
},
} 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
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;
}
}
@@ -38,7 +38,7 @@ async function generateDisplayId(): Promise<string> {
function findByIdOrDisplay(idOrDisplay: string) {
return prisma.ticket.findFirst({
where: { OR: [{ id: idOrDisplay }, { displayId: idOrDisplay }] },
})
});
}
const createSchema = z.object({
@@ -49,7 +49,7 @@ const createSchema = z.object({
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(),
@@ -60,28 +60,28 @@ const updateSchema = z.object({
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)
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 { 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
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({
@@ -95,60 +95,60 @@ router.get('/', async (req: AuthRequest, res) => {
_count: { select: { comments: true } },
},
orderBy: [{ severity: 'asc' }, { createdAt: 'desc' }],
})
});
res.json(tickets)
})
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' })
res.json(ticket)
})
});
if (!ticket) return res.status(404).json({ error: 'Ticket not found' });
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 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' },
})
});
res.json(logs)
})
res.json(logs);
});
// POST /api/tickets
router.post('/', requireAgent, async (req: AuthRequest, res) => {
const data = createSchema.parse(req.body)
const displayId = await generateDisplayId()
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 })
})
});
return tx.ticket.findUnique({ where: { id: created.id }, include: ticketInclude });
});
res.status(201).json(ticket)
})
res.status(201).json(ticket);
});
// PATCH /api/tickets/:id
router.patch('/:id', requireAgent, async (req: AuthRequest, res) => {
const data = updateSchema.parse(req.body)
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' })
return res.status(403).json({ error: 'Only admins can close tickets' });
}
const existing = await prisma.ticket.findFirst({
@@ -159,17 +159,17 @@ router.patch('/:id', requireAgent, async (req: AuthRequest, res) => {
item: true,
assignee: { select: { displayName: true } },
},
})
if (!existing) return res.status(404).json({ error: 'Ticket not found' })
});
if (!existing) return res.status(404).json({ error: 'Ticket not found' });
// Build audit entries
const auditEntries: { action: string; detail?: string }[] = []
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) {
@@ -178,25 +178,25 @@ router.patch('/:id', requireAgent, async (req: AuthRequest, res) => {
where: { id: data.assigneeId },
select: { displayName: true },
})
: null
: 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)
(data.itemId && data.itemId !== existing.itemId);
if (ctiChanged) {
const [newCat, newType, newItem] = await Promise.all([
@@ -209,34 +209,34 @@ router.patch('/:id', requireAgent, async (req: AuthRequest, res) => {
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 })
auditEntries.push({ action: 'TITLE_CHANGED', detail: data.title });
}
if (data.overview && data.overview !== existing.overview) {
auditEntries.push({ action: 'OVERVIEW_CHANGED' })
auditEntries.push({ action: 'OVERVIEW_CHANGED' });
}
// Handle resolvedAt tracking
const update: Record<string, unknown> = { ...data }
const update: Record<string, unknown> = { ...data };
if (data.status === 'RESOLVED' && existing.status !== 'RESOLVED') {
update.resolvedAt = new Date()
update.resolvedAt = new Date();
} else if (data.status && data.status !== 'RESOLVED' && existing.status === 'RESOLVED') {
update.resolvedAt = null
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) => ({
@@ -245,20 +245,20 @@ router.patch('/:id', requireAgent, async (req: AuthRequest, res) => {
action: e.action,
detail: e.detail ?? null,
})),
})
});
}
return tx.ticket.findUnique({ where: { id: updated.id }, include: ticketInclude })
})
return tx.ticket.findUnique({ where: { id: updated.id }, include: ticketInclude });
});
res.json(ticket)
})
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 } })
res.status(204).send()
})
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 } });
res.status(204).send();
});
export default router
export default router;
+42 -34
View File
@@ -1,11 +1,12 @@
import { Router } from 'express'
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { requireAdmin, AuthRequest } from '../middleware/auth'
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';
const router = Router()
const router = Router();
const userSelect = {
id: true,
@@ -15,15 +16,22 @@ const userSelect = {
role: true,
apiKey: true,
createdAt: true,
} as const
} 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 },
select: {
id: true,
username: true,
displayName: true,
email: true,
role: true,
createdAt: true,
},
orderBy: { displayName: 'asc' },
})
res.json(users)
})
});
res.json(users);
});
router.post('/', requireAdmin, async (req, res) => {
const data = z
@@ -34,14 +42,14 @@ router.post('/', requireAdmin, async (req, res) => {
password: z.string().min(8).optional(),
role: z.enum(['ADMIN', 'AGENT', 'USER', 'SERVICE']).default('AGENT'),
})
.parse(req.body)
.parse(req.body);
const passwordHash = data.password
? await bcrypt.hash(data.password, 12)
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12)
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12);
const apiKey =
data.role === 'SERVICE' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined
data.role === 'SERVICE' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined;
const user = await prisma.user.create({
data: {
@@ -53,10 +61,10 @@ router.post('/', requireAdmin, async (req, res) => {
apiKey,
},
select: userSelect,
})
});
res.status(201).json(user)
})
res.status(201).json(user);
});
router.patch('/:id', requireAdmin, async (req, res) => {
const data = z
@@ -67,37 +75,37 @@ router.patch('/:id', requireAdmin, async (req, res) => {
role: z.enum(['ADMIN', 'AGENT', 'USER', 'SERVICE']).optional(),
regenerateApiKey: z.boolean().optional(),
})
.parse(req.body)
.parse(req.body);
const update: Record<string, any> = {}
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)
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
update.role = data.role;
if (data.role === 'SERVICE' && !update.apiKey) {
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
}
}
if (data.regenerateApiKey) {
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`
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)
})
res.json(user);
});
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' })
return res.status(400).json({ error: 'Cannot delete your own account' });
}
await prisma.user.delete({ where: { id: req.params.id } })
res.status(204).send()
})
await prisma.user.delete({ where: { id: req.params.id } });
res.status(204).send();
});
export default router
export default router;