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
+8
View File
@@ -0,0 +1,8 @@
import { z } from 'zod';
export const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export type LoginInput = z.infer<typeof loginSchema>;
+7
View File
@@ -0,0 +1,7 @@
import { z } from 'zod';
export const commentSchema = z.object({
body: z.string().min(1),
});
export type CommentInput = z.infer<typeof commentSchema>;
+19
View File
@@ -0,0 +1,19 @@
import { z } from 'zod';
export const ctiNameSchema = z.object({
name: z.string().min(1).max(100),
});
export const createTypeSchema = z.object({
name: z.string().min(1).max(100),
categoryId: z.string().min(1),
});
export const createItemSchema = z.object({
name: z.string().min(1).max(100),
typeId: z.string().min(1),
});
export type CtiNameInput = z.infer<typeof ctiNameSchema>;
export type CreateTypeInput = z.infer<typeof createTypeSchema>;
export type CreateItemInput = z.infer<typeof createItemSchema>;
+14
View File
@@ -0,0 +1,14 @@
import { z } from 'zod';
export const ROLES = ['ADMIN', 'AGENT', 'USER', 'SERVICE'] as const;
export const TICKET_STATUSES = ['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'] as const;
export const roleSchema = z.enum(ROLES);
export const ticketStatusSchema = z.enum(TICKET_STATUSES);
export type Role = (typeof ROLES)[number];
export type TicketStatus = (typeof TICKET_STATUSES)[number];
export const SEVERITY_MIN = 1;
export const SEVERITY_MAX = 5;
export const severitySchema = z.number().int().min(SEVERITY_MIN).max(SEVERITY_MAX);
+6
View File
@@ -0,0 +1,6 @@
export * from './enums';
export * from './auth';
export * from './ticket';
export * from './comment';
export * from './user';
export * from './cti';
+26
View File
@@ -0,0 +1,26 @@
import { z } from 'zod';
import { severitySchema, ticketStatusSchema } from './enums';
export const createTicketSchema = z.object({
title: z.string().min(1).max(255),
overview: z.string().min(1),
severity: severitySchema,
categoryId: z.string().min(1),
typeId: z.string().min(1),
itemId: z.string().min(1),
assigneeId: z.string().optional(),
});
export const updateTicketSchema = z.object({
title: z.string().min(1).max(255).optional(),
overview: z.string().min(1).optional(),
severity: severitySchema.optional(),
status: ticketStatusSchema.optional(),
categoryId: z.string().min(1).optional(),
typeId: z.string().min(1).optional(),
itemId: z.string().min(1).optional(),
assigneeId: z.string().nullable().optional(),
});
export type CreateTicketInput = z.infer<typeof createTicketSchema>;
export type UpdateTicketInput = z.infer<typeof updateTicketSchema>;
+21
View File
@@ -0,0 +1,21 @@
import { z } from 'zod';
import { roleSchema } from './enums';
export const createUserSchema = 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: roleSchema.default('AGENT'),
});
export const updateUserSchema = z.object({
displayName: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
password: z.string().min(8).optional(),
role: roleSchema.optional(),
regenerateApiKey: z.boolean().optional(),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
+78
View File
@@ -0,0 +1,78 @@
import type { Role, TicketStatus } from './schemas/enums';
export type { Role, TicketStatus };
export interface UserSummary {
id: string;
username: string;
displayName: string;
}
export interface User extends UserSummary {
email: string;
role: Role;
apiKey?: string | null;
createdAt?: string;
}
export interface Category {
id: string;
name: string;
}
export interface CTIType {
id: string;
name: string;
categoryId: string;
category?: Category;
}
export interface Item {
id: string;
name: string;
typeId: string;
type?: CTIType & { category?: Category };
}
export interface Comment {
id: string;
body: string;
ticketId: string;
authorId: string;
author: UserSummary;
createdAt: string;
}
export interface AuditLog {
id: string;
ticketId: string;
userId: string;
action: string;
detail: string | null;
createdAt: string;
user: UserSummary;
}
export interface Ticket {
id: string;
displayId: string;
title: string;
overview: string;
severity: number;
status: TicketStatus;
categoryId: string;
typeId: string;
itemId: string;
assigneeId: string | null;
createdById: string;
resolvedAt: string | null;
createdAt: string;
updatedAt: string;
category: Category;
type: CTIType;
item: Item;
assignee: UserSummary | null;
createdBy: UserSummary;
comments?: Comment[];
_count?: { comments: number };
}