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:
2026-04-18 15:34:57 -04:00
parent 27d2ab0f0d
commit aff52e5672
38 changed files with 1260 additions and 2119 deletions
+71
View File
@@ -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),
});
});
});
+33
View File
@@ -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);
});
});
+45
View File
@@ -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,
},
}),
]);
}
+77
View File
@@ -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;
}
+8
View File
@@ -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 });
}
+134
View File
@@ -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' },
}),
);
});
});
+239
View File
@@ -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;
}
+61
View File
@@ -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' } });
});
});
+87
View File
@@ -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 } });
}