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
+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 } });
}