aboutsummaryrefslogtreecommitdiff
path: root/api/src/modules
diff options
context:
space:
mode:
Diffstat (limited to 'api/src/modules')
-rw-r--r--api/src/modules/auth/auth.controller.ts114
-rw-r--r--api/src/modules/auth/auth.repository.ts68
-rw-r--r--api/src/modules/auth/auth.routes.ts55
-rw-r--r--api/src/modules/auth/auth.schema.ts66
-rw-r--r--api/src/modules/categories/categories.controller.ts127
-rw-r--r--api/src/modules/categories/categories.repository.ts80
-rw-r--r--api/src/modules/categories/categories.routes.ts92
-rw-r--r--api/src/modules/categories/categories.schema.ts76
-rw-r--r--api/src/modules/interactions/interactions.controller.ts190
-rw-r--r--api/src/modules/interactions/interactions.repository.ts135
-rw-r--r--api/src/modules/interactions/interactions.routes.ts86
-rw-r--r--api/src/modules/interactions/interactions.schema.ts101
-rw-r--r--api/src/modules/settings/settings.controller.ts24
-rw-r--r--api/src/modules/settings/settings.repository.ts26
-rw-r--r--api/src/modules/settings/settings.routes.ts30
-rw-r--r--api/src/modules/settings/settings.schema.ts7
-rw-r--r--api/src/modules/stats/stats.controller.ts15
-rw-r--r--api/src/modules/stats/stats.repository.ts41
-rw-r--r--api/src/modules/stats/stats.routes.ts22
-rw-r--r--api/src/modules/stats/stats.schema.ts17
-rw-r--r--api/src/modules/tickets/tickets.controller.ts112
-rw-r--r--api/src/modules/tickets/tickets.repository.ts145
-rw-r--r--api/src/modules/tickets/tickets.routes.ts75
-rw-r--r--api/src/modules/tickets/tickets.schema.ts89
-rw-r--r--api/src/modules/users/users.controller.ts129
-rw-r--r--api/src/modules/users/users.routes.ts56
-rw-r--r--api/src/modules/users/users.schema.ts25
27 files changed, 2003 insertions, 0 deletions
diff --git a/api/src/modules/auth/auth.controller.ts b/api/src/modules/auth/auth.controller.ts
new file mode 100644
index 0000000..8aec643
--- /dev/null
+++ b/api/src/modules/auth/auth.controller.ts
@@ -0,0 +1,114 @@
+/**
+ * auth.controller.ts
+ * Lógica de negócio de autenticação.
+ */
+
+import bcrypt from 'bcryptjs';
+import type { FastifyReply, FastifyRequest } from 'fastify';
+import {
+ findUserByEmail,
+ findUserById,
+ createUser,
+} from './auth.repository.js';
+import type { RegisterBody, LoginBody } from './auth.schema.js';
+
+const SALT_ROUNDS = 12;
+
+// ─── POST /auth/register ──────────────────────────────────────────────────────
+
+export async function registerHandler(
+ request: FastifyRequest<{ Body: RegisterBody }>,
+ reply: FastifyReply,
+) {
+ const { name, email, password, role } = request.body;
+ const db = request.server.db;
+
+ // Verifica duplicidade de e-mail
+ const existing = await findUserByEmail(db, email);
+ if (existing) {
+ return reply.conflict('Este e-mail já está cadastrado.');
+ }
+
+ // Apenas admins podem criar agentes ou outros admins
+ if ((role === 'agent' || role === 'admin')) {
+ const callerUser = request.user as { role?: string } | undefined;
+ if (!callerUser || callerUser.role !== 'admin') {
+ return reply.forbidden('Apenas administradores podem criar agentes ou admins.');
+ }
+ }
+
+ // Hash da senha (bcrypt com salt 12 ≈ ~250ms — seguro e aceitável)
+ const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
+
+ const user = await createUser(db, {
+ name,
+ email,
+ password: hashedPassword,
+ role,
+ });
+
+ // Assina o JWT com o payload mínimo necessário
+ const token = await reply.jwtSign({
+ id: user.id,
+ email: user.email,
+ role: user.role,
+ });
+
+ return reply.code(201).send({ token, user });
+}
+
+// ─── POST /auth/login ─────────────────────────────────────────────────────────
+
+export async function loginHandler(
+ request: FastifyRequest<{ Body: LoginBody }>,
+ reply: FastifyReply,
+) {
+ const { email, password } = request.body;
+ const db = request.server.db;
+
+ const user = await findUserByEmail(db, email);
+
+ // Mensagem genérica: não revela se o e-mail existe ou não (segurança)
+ const INVALID_MSG = 'E-mail ou senha incorretos.';
+
+ if (!user) {
+ // Executa bcrypt mesmo sem usuário para evitar timing attacks
+ await bcrypt.compare(password, '$2b$12$invalidhashplaceholderthatnevermatchesanypassword');
+ return reply.unauthorized(INVALID_MSG);
+ }
+
+ if (!user.isActive) {
+ return reply.forbidden('Conta desativada. Entre em contato com o administrador.');
+ }
+
+ const isPasswordValid = await bcrypt.compare(password, user.password);
+ if (!isPasswordValid) {
+ return reply.unauthorized(INVALID_MSG);
+ }
+
+ const token = await reply.jwtSign({
+ id: user.id,
+ email: user.email,
+ role: user.role,
+ });
+
+ // Retorna o user sem o campo password
+ const { password: _pwd, ...safeUser } = user;
+ return reply.send({ token, user: safeUser });
+}
+
+// ─── GET /auth/me ─────────────────────────────────────────────────────────────
+
+export async function meHandler(
+ request: FastifyRequest,
+ reply: FastifyReply,
+) {
+ // request.user foi injetado pelo decorator authenticate
+ const user = await findUserById(request.server.db, request.user.id);
+
+ if (!user) {
+ return reply.unauthorized('Usuário não encontrado.');
+ }
+
+ return reply.send(user);
+}
diff --git a/api/src/modules/auth/auth.repository.ts b/api/src/modules/auth/auth.repository.ts
new file mode 100644
index 0000000..25a931b
--- /dev/null
+++ b/api/src/modules/auth/auth.repository.ts
@@ -0,0 +1,68 @@
+/**
+ * auth.repository.ts
+ * Queries de banco exclusivas do módulo de autenticação.
+ */
+
+import { eq } from 'drizzle-orm';
+import type { Database } from '@/db/connection.js';
+import { users, type NewUser } from '@/db/schema.js';
+
+// ─── Busca com senha (apenas para verificação no login) ───────────────────────
+
+export async function findUserByEmail(db: Database, email: string) {
+ const [user] = await db
+ .select()
+ .from(users)
+ .where(eq(users.email, email))
+ .limit(1);
+
+ return user ?? null;
+}
+
+// ─── Busca segura (sem password) ──────────────────────────────────────────────
+
+export async function findUserById(db: Database, id: number) {
+ const [user] = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ role: users.role,
+ isActive: users.isActive,
+ createdAt: users.createdAt,
+ updatedAt: users.updatedAt,
+ })
+ .from(users)
+ .where(eq(users.id, id))
+ .limit(1);
+
+ return user ?? null;
+}
+
+// ─── Criação de usuário ───────────────────────────────────────────────────────
+
+export async function createUser(
+ db: Database,
+ data: Pick<NewUser, 'name' | 'email' | 'password' | 'role'>,
+) {
+ const now = new Date().toISOString();
+
+ const [user] = await db
+ .insert(users)
+ .values({
+ ...data,
+ isActive: true,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .returning({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ role: users.role,
+ isActive: users.isActive,
+ createdAt: users.createdAt,
+ });
+
+ return user!;
+}
diff --git a/api/src/modules/auth/auth.routes.ts b/api/src/modules/auth/auth.routes.ts
new file mode 100644
index 0000000..1de0e16
--- /dev/null
+++ b/api/src/modules/auth/auth.routes.ts
@@ -0,0 +1,55 @@
+/**
+ * auth.routes.ts
+ *
+ * Rotas públicas e protegidas de autenticação:
+ * POST /api/v1/auth/register → cria conta
+ * POST /api/v1/auth/login → retorna JWT
+ * GET /api/v1/auth/me → dados do usuário logado (protegida)
+ */
+
+import type { FastifyInstance } from 'fastify';
+import {
+ registerBodySchema,
+ loginBodySchema,
+} from './auth.schema.js';
+import {
+ registerHandler,
+ loginHandler,
+ meHandler,
+} from './auth.controller.js';
+
+async function authRoutes(fastify: FastifyInstance): Promise<void> {
+
+ // ── POST /auth/register ────────────────────────────────────────────────────
+ fastify.post('/register', {
+ schema: {
+ tags: ['Auth'],
+ summary: 'Registra um novo usuário',
+ body: registerBodySchema,
+ },
+ handler: registerHandler,
+ });
+
+ // ── POST /auth/login ───────────────────────────────────────────────────────
+ fastify.post('/login', {
+ schema: {
+ tags: ['Auth'],
+ summary: 'Autentica e retorna um JWT',
+ body: loginBodySchema,
+ },
+ handler: loginHandler,
+ });
+
+ // ── GET /auth/me ───────────────────────────────────────────────────────────
+ fastify.get('/me', {
+ schema: {
+ tags: ['Auth'],
+ summary: 'Retorna o usuário autenticado',
+ security: [{ bearerAuth: [] }],
+ },
+ preHandler: [fastify.authenticate],
+ handler: meHandler,
+ });
+}
+
+export default authRoutes;
diff --git a/api/src/modules/auth/auth.schema.ts b/api/src/modules/auth/auth.schema.ts
new file mode 100644
index 0000000..7c9e8cd
--- /dev/null
+++ b/api/src/modules/auth/auth.schema.ts
@@ -0,0 +1,66 @@
+/**
+ * auth.schema.ts
+ * Schemas Zod para registro, login e resposta de usuário.
+ */
+
+import { z } from 'zod';
+import { USER_ROLES } from '@/db/schema.js';
+
+// ─── Registro ─────────────────────────────────────────────────────────────────
+
+export const registerBodySchema = z.object({
+ name: z
+ .string({ required_error: 'Nome é obrigatório' })
+ .min(2, 'Nome deve ter no mínimo 2 caracteres')
+ .max(100)
+ .trim(),
+
+ email: z
+ .string({ required_error: 'E-mail é obrigatório' })
+ .email('E-mail inválido')
+ .toLowerCase()
+ .trim(),
+
+ password: z
+ .string({ required_error: 'Senha é obrigatória' })
+ .min(8, 'Senha deve ter no mínimo 8 caracteres')
+ .max(100),
+
+ // Somente admins podem criar agentes/admins — validado no controller
+ role: z.enum(USER_ROLES).default('requester'),
+});
+
+export type RegisterBody = z.infer<typeof registerBodySchema>;
+
+// ─── Login ────────────────────────────────────────────────────────────────────
+
+export const loginBodySchema = z.object({
+ email: z
+ .string({ required_error: 'E-mail é obrigatório' })
+ .email('E-mail inválido')
+ .toLowerCase()
+ .trim(),
+
+ password: z.string({ required_error: 'Senha é obrigatória' }),
+});
+
+export type LoginBody = z.infer<typeof loginBodySchema>;
+
+// ─── Shape de resposta (NUNCA inclui password) ────────────────────────────────
+
+export const userResponseSchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ email: z.string(),
+ role: z.enum(USER_ROLES),
+ isActive: z.boolean(),
+ createdAt: z.string(),
+});
+
+export const authResponseSchema = z.object({
+ token: z.string(),
+ user: userResponseSchema,
+});
+
+export type UserResponse = z.infer<typeof userResponseSchema>;
+export type AuthResponse = z.infer<typeof authResponseSchema>;
diff --git a/api/src/modules/categories/categories.controller.ts b/api/src/modules/categories/categories.controller.ts
new file mode 100644
index 0000000..9327932
--- /dev/null
+++ b/api/src/modules/categories/categories.controller.ts
@@ -0,0 +1,127 @@
+/**
+ * categories.controller.ts
+ * Lógica de negócio do CRUD de categorias.
+ */
+
+import type { FastifyReply, FastifyRequest } from 'fastify';
+import {
+ findAllCategories,
+ findCategoryById,
+ findCategoryBySlug,
+ createCategory,
+ updateCategory,
+ deleteCategory,
+} from './categories.repository.js';
+import type {
+ CategoryParams,
+ CreateCategoryBody,
+ UpdateCategoryBody,
+} from './categories.schema.js';
+import { eq, count } from 'drizzle-orm';
+import { tickets } from '@/db/schema.js';
+
+// ─── GET /categories ──────────────────────────────────────────────────────────
+
+export async function listCategoriesHandler(
+ request: FastifyRequest,
+ reply: FastifyReply,
+) {
+ const list = await findAllCategories(request.server.db);
+ return reply.send(list);
+}
+
+// ─── GET /categories/:id ──────────────────────────────────────────────────────
+
+export async function getCategoryHandler(
+ request: FastifyRequest<{ Params: CategoryParams }>,
+ reply: FastifyReply,
+) {
+ const category = await findCategoryById(request.server.db, request.params.id);
+
+ if (!category) {
+ return reply.notFound(`Categoria #${request.params.id} não encontrada.`);
+ }
+
+ return reply.send(category);
+}
+
+// ─── POST /categories ─────────────────────────────────────────────────────────
+
+export async function createCategoryHandler(
+ request: FastifyRequest<{ Body: CreateCategoryBody }>,
+ reply: FastifyReply,
+) {
+ const { name, slug, description } = request.body;
+ const db = request.server.db;
+
+ // Slug deve ser único
+ const existing = await findCategoryBySlug(db, slug);
+ if (existing) {
+ return reply.conflict(`Já existe uma categoria com o slug "${slug}".`);
+ }
+
+ const category = await createCategory(db, {
+ name,
+ slug,
+ description: description ?? null,
+ });
+
+ return reply.code(201).send(category);
+}
+
+// ─── PATCH /categories/:id ────────────────────────────────────────────────────
+
+export async function updateCategoryHandler(
+ request: FastifyRequest<{ Params: CategoryParams; Body: UpdateCategoryBody }>,
+ reply: FastifyReply,
+) {
+ const { id } = request.params;
+ const db = request.server.db;
+
+ const existing = await findCategoryById(db, id);
+ if (!existing) {
+ return reply.notFound(`Categoria #${id} não encontrada.`);
+ }
+
+ // Se o slug está sendo alterado, verifica conflito com outro registro
+ if (request.body.slug && request.body.slug !== existing.slug) {
+ const slugConflict = await findCategoryBySlug(db, request.body.slug);
+ if (slugConflict) {
+ return reply.conflict(`Já existe uma categoria com o slug "${request.body.slug}".`);
+ }
+ }
+
+ const updated = await updateCategory(db, id, request.body);
+ return reply.send(updated);
+}
+
+// ─── DELETE /categories/:id ───────────────────────────────────────────────────
+
+export async function deleteCategoryHandler(
+ request: FastifyRequest<{ Params: CategoryParams }>,
+ reply: FastifyReply,
+) {
+ const { id } = request.params;
+ const db = request.server.db;
+
+ const existing = await findCategoryById(db, id);
+ if (!existing) {
+ return reply.notFound(`Categoria #${id} não encontrada.`);
+ }
+
+ // Protege contra deleção de categoria em uso
+ const [{ total }] = await db
+ .select({ total: count() })
+ .from(tickets)
+ .where(eq(tickets.categoryId, id));
+
+ if (total > 0) {
+ return reply.conflict(
+ `Categoria em uso por ${total} chamado(s). Reclassifique-os antes de excluir.`,
+ );
+ }
+
+ await deleteCategory(db, id);
+
+ return reply.code(204).send();
+}
diff --git a/api/src/modules/categories/categories.repository.ts b/api/src/modules/categories/categories.repository.ts
new file mode 100644
index 0000000..1ca5901
--- /dev/null
+++ b/api/src/modules/categories/categories.repository.ts
@@ -0,0 +1,80 @@
+/**
+ * categories.repository.ts
+ * Queries de banco para o CRUD de categorias.
+ */
+
+import { eq, asc } from 'drizzle-orm';
+import type { Database } from '@/db/connection.js';
+import { categories, type NewCategory } from '@/db/schema.js';
+
+// ─── Leitura ──────────────────────────────────────────────────────────────────
+
+export async function findAllCategories(db: Database) {
+ return db
+ .select()
+ .from(categories)
+ .orderBy(asc(categories.name));
+}
+
+export async function findCategoryById(db: Database, id: number) {
+ const [cat] = await db
+ .select()
+ .from(categories)
+ .where(eq(categories.id, id))
+ .limit(1);
+
+ return cat ?? null;
+}
+
+export async function findCategoryBySlug(db: Database, slug: string) {
+ const [cat] = await db
+ .select()
+ .from(categories)
+ .where(eq(categories.slug, slug))
+ .limit(1);
+
+ return cat ?? null;
+}
+
+// ─── Criação ──────────────────────────────────────────────────────────────────
+
+export async function createCategory(
+ db: Database,
+ data: Pick<NewCategory, 'name' | 'slug' | 'description'>,
+) {
+ const now = new Date().toISOString();
+
+ const [category] = await db
+ .insert(categories)
+ .values({ ...data, createdAt: now, updatedAt: now })
+ .returning();
+
+ return category!;
+}
+
+// ─── Atualização ──────────────────────────────────────────────────────────────
+
+export async function updateCategory(
+ db: Database,
+ id: number,
+ data: Partial<Pick<NewCategory, 'name' | 'slug' | 'description'>>,
+) {
+ const [updated] = await db
+ .update(categories)
+ .set({ ...data, updatedAt: new Date().toISOString() })
+ .where(eq(categories.id, id))
+ .returning();
+
+ return updated ?? null;
+}
+
+// ─── Exclusão ─────────────────────────────────────────────────────────────────
+
+export async function deleteCategory(db: Database, id: number) {
+ const [deleted] = await db
+ .delete(categories)
+ .where(eq(categories.id, id))
+ .returning();
+
+ return deleted ?? null;
+}
diff --git a/api/src/modules/categories/categories.routes.ts b/api/src/modules/categories/categories.routes.ts
new file mode 100644
index 0000000..cdf3602
--- /dev/null
+++ b/api/src/modules/categories/categories.routes.ts
@@ -0,0 +1,92 @@
+/**
+ * categories.routes.ts
+ *
+ * CRUD de categorias com controle de acesso por role:
+ * GET /api/v1/categories → lista todas (qualquer autenticado)
+ * GET /api/v1/categories/:id → busca por ID (qualquer autenticado)
+ * POST /api/v1/categories → cria (admin)
+ * PATCH /api/v1/categories/:id → atualiza (admin)
+ * DELETE /api/v1/categories/:id → exclui (admin)
+ */
+
+import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
+import {
+ categoryParamsSchema,
+ createCategoryBodySchema,
+ updateCategoryBodySchema,
+} from './categories.schema.js';
+import {
+ listCategoriesHandler,
+ getCategoryHandler,
+ createCategoryHandler,
+ updateCategoryHandler,
+ deleteCategoryHandler,
+} from './categories.controller.js';
+
+// ─── Guard de role ────────────────────────────────────────────────────────────
+// Verificação de permissão após autenticação
+async function requireAdmin(request: FastifyRequest, reply: FastifyReply) {
+ if (request.user.role !== 'admin') {
+ await reply.forbidden('Apenas administradores podem gerenciar categorias.');
+ }
+}
+
+async function categoriesRoutes(fastify: FastifyInstance): Promise<void> {
+
+ // ── GET /categories ────────────────────────────────────────────────────────
+ fastify.get('/', {
+ schema: {
+ tags: ['Categorias'],
+ summary: 'Lista todas as categorias',
+ },
+ preHandler: [fastify.authenticate],
+ handler: listCategoriesHandler,
+ });
+
+ // ── GET /categories/:id ────────────────────────────────────────────────────
+ fastify.get('/:id', {
+ schema: {
+ tags: ['Categorias'],
+ summary: 'Busca uma categoria pelo ID',
+ params: categoryParamsSchema,
+ },
+ preHandler: [fastify.authenticate],
+ handler: getCategoryHandler,
+ });
+
+ // ── POST /categories ───────────────────────────────────────────────────────
+ fastify.post('/', {
+ schema: {
+ tags: ['Categorias'],
+ summary: 'Cria uma nova categoria (admin)',
+ body: createCategoryBodySchema,
+ },
+ preHandler: [fastify.authenticate, requireAdmin],
+ handler: createCategoryHandler,
+ });
+
+ // ── PATCH /categories/:id ──────────────────────────────────────────────────
+ fastify.patch('/:id', {
+ schema: {
+ tags: ['Categorias'],
+ summary: 'Atualiza uma categoria (admin)',
+ params: categoryParamsSchema,
+ body: updateCategoryBodySchema,
+ },
+ preHandler: [fastify.authenticate, requireAdmin],
+ handler: updateCategoryHandler,
+ });
+
+ // ── DELETE /categories/:id ─────────────────────────────────────────────────
+ fastify.delete('/:id', {
+ schema: {
+ tags: ['Categorias'],
+ summary: 'Exclui uma categoria (admin)',
+ params: categoryParamsSchema,
+ },
+ preHandler: [fastify.authenticate, requireAdmin],
+ handler: deleteCategoryHandler,
+ });
+}
+
+export default categoriesRoutes;
diff --git a/api/src/modules/categories/categories.schema.ts b/api/src/modules/categories/categories.schema.ts
new file mode 100644
index 0000000..6e11d8c
--- /dev/null
+++ b/api/src/modules/categories/categories.schema.ts
@@ -0,0 +1,76 @@
+/**
+ * categories.schema.ts
+ * Schemas Zod para o CRUD de categorias.
+ */
+
+import { z } from 'zod';
+
+// ─── Helper: gerador de slug ──────────────────────────────────────────────────
+// Converte "Hardware e Periféricos" → "hardware-e-perifericos"
+function toSlug(name: string): string {
+ return name
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '') // remove acentos
+ .replace(/[^a-z0-9\s-]/g, '') // remove caracteres especiais
+ .trim()
+ .replace(/\s+/g, '-'); // espaços → hífens
+}
+
+// ─── Criação ──────────────────────────────────────────────────────────────────
+
+export const createCategoryBodySchema = z.object({
+ name: z
+ .string({ required_error: 'Nome é obrigatório' })
+ .min(2, 'Nome deve ter no mínimo 2 caracteres')
+ .max(80)
+ .trim(),
+
+ // Se não informado, será gerado automaticamente a partir do name
+ slug: z
+ .string()
+ .min(2)
+ .max(80)
+ .regex(/^[a-z0-9-]+$/, 'Slug deve conter apenas letras minúsculas, números e hífens')
+ .optional(),
+
+ description: z.string().max(300).trim().optional(),
+}).transform((data) => ({
+ ...data,
+ // Garante que slug sempre exista — gerado do name se ausente
+ slug: data.slug ?? toSlug(data.name),
+}));
+
+export type CreateCategoryBody = z.infer<typeof createCategoryBodySchema>;
+
+// ─── Atualização (PATCH parcial) ──────────────────────────────────────────────
+
+export const updateCategoryBodySchema = z.object({
+ name: z.string().min(2).max(80).trim().optional(),
+ slug: z.string().min(2).max(80)
+ .regex(/^[a-z0-9-]+$/, 'Slug inválido').optional(),
+ description: z.string().max(300).trim().nullable().optional(),
+});
+
+export type UpdateCategoryBody = z.infer<typeof updateCategoryBodySchema>;
+
+// ─── Parâmetros de rota ───────────────────────────────────────────────────────
+
+export const categoryParamsSchema = z.object({
+ id: z.coerce.number().int().positive(),
+});
+
+export type CategoryParams = z.infer<typeof categoryParamsSchema>;
+
+// ─── Resposta ─────────────────────────────────────────────────────────────────
+
+export const categoryResponseSchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ slug: z.string(),
+ description: z.string().nullable(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+});
+
+export type CategoryResponse = z.infer<typeof categoryResponseSchema>;
diff --git a/api/src/modules/interactions/interactions.controller.ts b/api/src/modules/interactions/interactions.controller.ts
new file mode 100644
index 0000000..5008272
--- /dev/null
+++ b/api/src/modules/interactions/interactions.controller.ts
@@ -0,0 +1,190 @@
+/**
+ * interactions.controller.ts
+ *
+ * Lógica de negócio para todas as interações sobre tickets.
+ * Orquestra o repository, aplica guards e formata respostas.
+ */
+
+import type { FastifyReply, FastifyRequest } from 'fastify';
+import { findTicketById } from '@/modules/tickets/tickets.repository.js';
+import {
+ updateTicketStatus,
+ assignTicket,
+ updateTicketPriority,
+ addComment,
+ findCommentsByTicketId,
+} from './interactions.repository.js';
+import { getSettings } from '@/modules/settings/settings.repository.js';
+import type {
+ TicketIdParams,
+ UpdateTicketStatusBody,
+ AssignTicketBody,
+ UpdateTicketPriorityBody,
+ AddCommentBody,
+} from './interactions.schema.js';
+
+// ─── Guard reutilizável ───────────────────────────────────────────────────────
+// Verifica se o ticket existe antes de qualquer operação
+
+async function assertTicketExists(
+ request: FastifyRequest,
+ reply: FastifyReply,
+ ticketId: number,
+) {
+ const ticket = await findTicketById(request.server.db, ticketId);
+ if (!ticket) {
+ await reply.notFound(`Ticket #${ticketId} não encontrado`);
+ return null;
+ }
+ return ticket;
+}
+
+// ─── PATCH /tickets/:ticketId/status ─────────────────────────────────────────
+
+export async function updateTicketStatusHandler(
+ request: FastifyRequest<{ Params: TicketIdParams; Body: UpdateTicketStatusBody }>,
+ reply: FastifyReply,
+) {
+ const { ticketId } = request.params;
+ const { status, reason } = request.body;
+ const db = request.server.db;
+
+ const ticket = await assertTicketExists(request, reply, ticketId);
+ if (!ticket) return;
+
+ // Impede mover um ticket já fechado
+ if (ticket.status === 'closed') {
+ return reply.badRequest('Tickets fechados não podem ser reabertos por esta rota.');
+ }
+
+ const updated = await updateTicketStatus(db, ticketId, status);
+
+ // Se um motivo foi fornecido, registra automaticamente como nota interna
+ if (reason && request.body.authorId) {
+ await addComment(db, {
+ ticketId,
+ authorId: (request.body as { authorId?: number }).authorId ?? 0,
+ content: `Status alterado para **${status}**. Motivo: ${reason}`,
+ isInternal: true,
+ });
+ }
+
+ if (updated) {
+ request.server.events.emit('ticket.updated', {
+ id: updated.id,
+ status: updated.status,
+ assigneeId: updated.assigneeId,
+ creatorId: ticket.creatorId,
+ authorId: request.user!.id,
+ });
+ }
+
+ return reply.send(updated);
+}
+
+// ─── PATCH /tickets/:ticketId/assign ─────────────────────────────────────────
+
+export async function assignTicketHandler(
+ request: FastifyRequest<{ Params: TicketIdParams; Body: AssignTicketBody }>,
+ reply: FastifyReply,
+) {
+ const { ticketId } = request.params;
+ const db = request.server.db;
+
+ const ticket = await assertTicketExists(request, reply, ticketId);
+ if (!ticket) return;
+
+ const updated = await assignTicket(db, ticketId, request.body.assigneeId);
+
+ if (updated) {
+ request.server.events.emit('ticket.updated', {
+ id: updated.id,
+ status: updated.status,
+ assigneeId: updated.assigneeId,
+ creatorId: ticket.creatorId,
+ authorId: request.user!.id,
+ });
+ }
+
+ return reply.send(updated);
+}
+
+// ─── PATCH /tickets/:ticketId/priority ───────────────────────────────────────
+
+export async function updateTicketPriorityHandler(
+ request: FastifyRequest<{ Params: TicketIdParams; Body: UpdateTicketPriorityBody }>,
+ reply: FastifyReply,
+) {
+ const { ticketId } = request.params;
+
+ const ticket = await assertTicketExists(request, reply, ticketId);
+ if (!ticket) return;
+
+ const updated = await updateTicketPriority(
+ request.server.db,
+ ticketId,
+ request.body.priority,
+ );
+
+ return reply.send(updated);
+}
+
+// ─── POST /tickets/:ticketId/comments ────────────────────────────────────────
+
+export async function addCommentHandler(
+ request: FastifyRequest<{ Params: TicketIdParams; Body: AddCommentBody }>,
+ reply: FastifyReply,
+) {
+ const { ticketId } = request.params;
+ const { content, isInternal, authorId } = request.body;
+
+ const ticket = await assertTicketExists(request, reply, ticketId);
+ if (!ticket) return;
+
+ // Não permite adicionar comentários em tickets fechados ou resolvidos
+ if (ticket.status === 'closed' || ticket.status === 'resolved') {
+ return reply.badRequest('Não é possível comentar em um ticket fechado ou resolvido.');
+ }
+
+ // Verifica a configuração do sistema para bloqueio de chat de requesters
+ if (request.user?.role === 'requester') {
+ const settings = await getSettings(request.server.db);
+ if (!settings.allowRequesterChat) {
+ return reply.forbidden('O chat de chamados está desativado para usuários padrão no momento.');
+ }
+ }
+
+ const comment = await addComment(request.server.db, {
+ ticketId,
+ authorId,
+ content,
+ isInternal,
+ });
+
+ request.server.events.emit('comment.added', {
+ ticketId: comment.ticketId,
+ authorId: comment.authorId,
+ isInternal: comment.isInternal,
+ creatorId: ticket.creatorId,
+ assigneeId: ticket.assigneeId,
+ });
+
+ return reply.code(201).send(comment);
+}
+
+// ─── GET /tickets/:ticketId/comments ─────────────────────────────────────────
+
+export async function listCommentsHandler(
+ request: FastifyRequest<{ Params: TicketIdParams }>,
+ reply: FastifyReply,
+) {
+ const { ticketId } = request.params;
+
+ const ticket = await assertTicketExists(request, reply, ticketId);
+ if (!ticket) return;
+
+ const includeInternal = request.user!.role !== 'requester';
+ const result = await findCommentsByTicketId(request.server.db, ticketId, includeInternal);
+
+ return reply.send(result);
+}
diff --git a/api/src/modules/interactions/interactions.repository.ts b/api/src/modules/interactions/interactions.repository.ts
new file mode 100644
index 0000000..1a32dc0
--- /dev/null
+++ b/api/src/modules/interactions/interactions.repository.ts
@@ -0,0 +1,135 @@
+/**
+ * interactions.repository.ts
+ *
+ * Queries de banco para todas as interações sobre tickets existentes.
+ * Única camada que toca o Drizzle neste módulo.
+ */
+
+import { eq } from 'drizzle-orm';
+import type { Database } from '@/db/connection.js';
+import { tickets, comments, users } from '@/db/schema.js';
+import type { TicketStatus, TicketPriority } from '@/db/schema.js';
+
+// ─── Tickets ──────────────────────────────────────────────────────────────────
+
+export async function updateTicketStatus(
+ db: Database,
+ ticketId: number,
+ status: TicketStatus,
+) {
+ const now = new Date().toISOString();
+
+ const [updated] = await db
+ .update(tickets)
+ .set({
+ status,
+ updatedAt: now,
+ // Registra o momento exato da resolução para métricas de SLA
+ resolvedAt: status === 'resolved' || status === 'closed' ? now : null,
+ })
+ .where(eq(tickets.id, ticketId))
+ .returning();
+
+ return updated ?? null;
+}
+
+export async function assignTicket(
+ db: Database,
+ ticketId: number,
+ assigneeId: number,
+) {
+ const [updated] = await db
+ .update(tickets)
+ .set({
+ assigneeId,
+ // Atribuir um agente muda automaticamente o status de 'open' → 'in_progress'
+ status: 'in_progress',
+ updatedAt: new Date().toISOString(),
+ })
+ .where(eq(tickets.id, ticketId))
+ .returning();
+
+ return updated ?? null;
+}
+
+export async function updateTicketPriority(
+ db: Database,
+ ticketId: number,
+ priority: TicketPriority,
+) {
+ const [updated] = await db
+ .update(tickets)
+ .set({
+ priority,
+ updatedAt: new Date().toISOString(),
+ })
+ .where(eq(tickets.id, ticketId))
+ .returning();
+
+ return updated ?? null;
+}
+
+// ─── Comentários ──────────────────────────────────────────────────────────────
+
+export async function addComment(
+ db: Database,
+ data: {
+ ticketId: number;
+ authorId: number;
+ content: string;
+ isInternal: boolean;
+ },
+) {
+ const now = new Date().toISOString();
+
+ const [comment] = await db
+ .insert(comments)
+ .values({
+ ...data,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .returning();
+
+ return comment;
+}
+
+export async function findCommentsByTicketId(
+ db: Database,
+ ticketId: number,
+ // Filtro: agentes veem tudo, solicitantes só veem comentários públicos
+ includeInternal: boolean = true,
+) {
+ const rows = await db
+ .select({
+ id: comments.id,
+ ticketId: comments.ticketId,
+ authorId: comments.authorId,
+ content: comments.content,
+ isInternal: comments.isInternal,
+ createdAt: comments.createdAt,
+ updatedAt: comments.updatedAt,
+ authorName: users.name,
+ })
+ .from(comments)
+ .leftJoin(users, eq(comments.authorId, users.id))
+ .where(eq(comments.ticketId, ticketId))
+ .orderBy(comments.createdAt); // Cronológico: mais antigos primeiro
+
+ // Filtra notas internas em memória (conjunto pequeno por ticket)
+ return includeInternal
+ ? rows
+ : rows.filter((c) => !c.isInternal);
+}
+
+export async function deleteComment(
+ db: Database,
+ commentId: number,
+) {
+ const [deleted] = await db
+ .delete(comments)
+ .where(eq(comments.id, commentId))
+ .returning();
+
+ return deleted ?? null;
+}
diff --git a/api/src/modules/interactions/interactions.routes.ts b/api/src/modules/interactions/interactions.routes.ts
new file mode 100644
index 0000000..9487fcd
--- /dev/null
+++ b/api/src/modules/interactions/interactions.routes.ts
@@ -0,0 +1,86 @@
+/**
+ * interactions.routes.ts
+ *
+ * Plugin Fastify com as rotas de interação sobre tickets.
+ * Registrado no server.ts com prefixo '/api/v1'.
+ */
+
+import type { FastifyInstance } from 'fastify';
+import {
+ ticketIdParamsSchema,
+ updateTicketStatusBodySchema,
+ assignTicketBodySchema,
+ updateTicketPriorityBodySchema,
+ addCommentBodySchema,
+} from './interactions.schema.js';
+import {
+ updateTicketStatusHandler,
+ assignTicketHandler,
+ updateTicketPriorityHandler,
+ addCommentHandler,
+ listCommentsHandler,
+} from './interactions.controller.js';
+
+async function interactionsRoutes(fastify: FastifyInstance): Promise<void> {
+
+ // ── PATCH /tickets/:ticketId/status ────────────────────────────────────────
+ fastify.patch('/tickets/:ticketId/status', {
+ schema: {
+ tags: ['Interações'],
+ summary: 'Atualiza o status de um chamado',
+ params: ticketIdParamsSchema,
+ body: updateTicketStatusBodySchema,
+ },
+ preHandler: [fastify.authenticate],
+ handler: updateTicketStatusHandler,
+ });
+
+ // ── PATCH /tickets/:ticketId/assign ────────────────────────────────────────
+ fastify.patch('/tickets/:ticketId/assign', {
+ schema: {
+ tags: ['Interações'],
+ summary: 'Atribui ou reatribui um chamado a um agente',
+ params: ticketIdParamsSchema,
+ body: assignTicketBodySchema,
+ },
+ preHandler: [fastify.authenticate],
+ handler: assignTicketHandler,
+ });
+
+ // ── PATCH /tickets/:ticketId/priority ──────────────────────────────────────
+ fastify.patch('/tickets/:ticketId/priority', {
+ schema: {
+ tags: ['Interações'],
+ summary: 'Altera a prioridade de um chamado',
+ params: ticketIdParamsSchema,
+ body: updateTicketPriorityBodySchema,
+ },
+ preHandler: [fastify.authenticate],
+ handler: updateTicketPriorityHandler,
+ });
+
+ // ── POST /tickets/:ticketId/comments ───────────────────────────────────────
+ fastify.post('/tickets/:ticketId/comments', {
+ schema: {
+ tags: ['Interações'],
+ summary: 'Adiciona um comentário (público ou nota interna) no chamado',
+ params: ticketIdParamsSchema,
+ body: addCommentBodySchema,
+ },
+ preHandler: [fastify.authenticate],
+ handler: addCommentHandler,
+ });
+
+ // ── GET /tickets/:ticketId/comments ────────────────────────────────────────
+ fastify.get('/tickets/:ticketId/comments', {
+ schema: {
+ tags: ['Interações'],
+ summary: 'Lista todos os comentários de um chamado',
+ params: ticketIdParamsSchema,
+ },
+ preHandler: [fastify.authenticate],
+ handler: listCommentsHandler,
+ });
+}
+
+export default interactionsRoutes;
diff --git a/api/src/modules/interactions/interactions.schema.ts b/api/src/modules/interactions/interactions.schema.ts
new file mode 100644
index 0000000..59eec29
--- /dev/null
+++ b/api/src/modules/interactions/interactions.schema.ts
@@ -0,0 +1,101 @@
+/**
+ * interactions.schema.ts
+ *
+ * Schemas Zod para o módulo de interações:
+ * - Atualização de status de um ticket
+ * - Atribuição de agente
+ * - Adição de comentários / notas internas
+ */
+
+import { z } from 'zod';
+import { TICKET_STATUSES, TICKET_PRIORITIES } from '@/db/schema.js';
+
+// ─── Parâmetros de rota compartilhados ───────────────────────────────────────
+
+export const ticketIdParamsSchema = z.object({
+ ticketId: z.coerce.number().int().positive(),
+});
+
+export const commentIdParamsSchema = z.object({
+ ticketId: z.coerce.number().int().positive(),
+ commentId: z.coerce.number().int().positive(),
+});
+
+export type TicketIdParams = z.infer<typeof ticketIdParamsSchema>;
+export type CommentIdParams = z.infer<typeof commentIdParamsSchema>;
+
+// ─── Atualização de status ────────────────────────────────────────────────────
+
+export const updateTicketStatusBodySchema = z.object({
+ status: z.enum(TICKET_STATUSES, {
+ errorMap: () => ({
+ message: `Status deve ser um de: ${TICKET_STATUSES.join(', ')}`,
+ }),
+ }),
+
+ // Motivo da mudança — obrigatório ao fechar ou resolver um chamado
+ reason: z.string().trim().max(500).optional(),
+});
+
+export type UpdateTicketStatusBody = z.infer<typeof updateTicketStatusBodySchema>;
+
+// ─── Atribuição de agente ─────────────────────────────────────────────────────
+
+export const assignTicketBodySchema = z.object({
+ assigneeId: z
+ .number({ required_error: 'assigneeId é obrigatório' })
+ .int()
+ .positive(),
+});
+
+export type AssignTicketBody = z.infer<typeof assignTicketBodySchema>;
+
+// ─── Atualização de prioridade ────────────────────────────────────────────────
+
+export const updateTicketPriorityBodySchema = z.object({
+ priority: z.enum(TICKET_PRIORITIES, {
+ errorMap: () => ({
+ message: `Prioridade deve ser uma de: ${TICKET_PRIORITIES.join(', ')}`,
+ }),
+ }),
+});
+
+export type UpdateTicketPriorityBody = z.infer<typeof updateTicketPriorityBodySchema>;
+
+// ─── Adição de comentário / nota interna ─────────────────────────────────────
+
+export const addCommentBodySchema = z.object({
+ content: z
+ .string({ required_error: 'Conteúdo é obrigatório' })
+ .min(1, 'Comentário não pode ser vazio')
+ .max(5000, 'Comentário muito longo')
+ .trim(),
+
+ /**
+ * true → nota interna (só agentes/admins veem)
+ * false → resposta pública (solicitante recebe notificação)
+ */
+ isInternal: z.boolean().default(false),
+
+ // Em produção viria do JWT; por ora, aceito no body
+ authorId: z
+ .number({ required_error: 'authorId é obrigatório' })
+ .int()
+ .positive(),
+});
+
+export type AddCommentBody = z.infer<typeof addCommentBodySchema>;
+
+// ─── Shape de resposta dos comentários ───────────────────────────────────────
+
+export const commentResponseSchema = z.object({
+ id: z.number(),
+ content: z.string(),
+ isInternal: z.boolean(),
+ ticketId: z.number(),
+ authorId: z.number(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+});
+
+export type CommentResponse = z.infer<typeof commentResponseSchema>;
diff --git a/api/src/modules/settings/settings.controller.ts b/api/src/modules/settings/settings.controller.ts
new file mode 100644
index 0000000..479f476
--- /dev/null
+++ b/api/src/modules/settings/settings.controller.ts
@@ -0,0 +1,24 @@
+import type { FastifyReply, FastifyRequest } from 'fastify';
+import { getSettings, updateSettings } from './settings.repository.js';
+import type { UpdateSettingsBody } from './settings.schema.js';
+
+export async function getSettingsHandler(
+ request: FastifyRequest,
+ reply: FastifyReply,
+) {
+ const settings = await getSettings(request.server.db);
+ return reply.send(settings);
+}
+
+export async function updateSettingsHandler(
+ request: FastifyRequest<{ Body: UpdateSettingsBody }>,
+ reply: FastifyReply,
+) {
+ // Apenas admins podem atualizar as configurações globais
+ if (request.user?.role !== 'admin') {
+ return reply.forbidden('Apenas administradores podem alterar configurações do sistema.');
+ }
+
+ const updated = await updateSettings(request.server.db, request.body);
+ return reply.send(updated);
+}
diff --git a/api/src/modules/settings/settings.repository.ts b/api/src/modules/settings/settings.repository.ts
new file mode 100644
index 0000000..a3d38ba
--- /dev/null
+++ b/api/src/modules/settings/settings.repository.ts
@@ -0,0 +1,26 @@
+import type { Database } from '@/db/connection.js';
+import { systemSettings } from '@/db/schema.js';
+
+export async function getSettings(db: Database) {
+ let [settings] = await db.select().from(systemSettings).limit(1);
+
+ if (!settings) {
+ [settings] = await db.insert(systemSettings).values({ allowRequesterChat: true }).returning();
+ }
+
+ return settings;
+}
+
+export async function updateSettings(db: Database, data: { allowRequesterChat: boolean }) {
+ let [settings] = await db.select().from(systemSettings).limit(1);
+
+ if (!settings) {
+ [settings] = await db.insert(systemSettings).values(data).returning();
+ } else {
+ [settings] = await db.update(systemSettings)
+ .set({ allowRequesterChat: data.allowRequesterChat, updatedAt: new Date().toISOString() })
+ .returning();
+ }
+
+ return settings;
+}
diff --git a/api/src/modules/settings/settings.routes.ts b/api/src/modules/settings/settings.routes.ts
new file mode 100644
index 0000000..5ae97da
--- /dev/null
+++ b/api/src/modules/settings/settings.routes.ts
@@ -0,0 +1,30 @@
+import type { FastifyInstance } from 'fastify';
+import { getSettingsHandler, updateSettingsHandler } from './settings.controller.js';
+import { SettingsSchema } from './settings.schema.js';
+
+export default async function settingsRoutes(fastify: FastifyInstance) {
+ fastify.get(
+ '/',
+ {
+ schema: {
+ tags: ['Settings'],
+ summary: 'Obtém as configurações globais do sistema',
+ },
+ preHandler: [fastify.authenticate],
+ },
+ getSettingsHandler,
+ );
+
+ fastify.patch(
+ '/',
+ {
+ schema: {
+ tags: ['Settings'],
+ summary: 'Atualiza as configurações globais do sistema (apenas admin)',
+ body: SettingsSchema,
+ },
+ preHandler: [fastify.authenticate],
+ },
+ updateSettingsHandler,
+ );
+}
diff --git a/api/src/modules/settings/settings.schema.ts b/api/src/modules/settings/settings.schema.ts
new file mode 100644
index 0000000..cd3a19b
--- /dev/null
+++ b/api/src/modules/settings/settings.schema.ts
@@ -0,0 +1,7 @@
+import { z } from 'zod';
+
+export const SettingsSchema = z.object({
+ allowRequesterChat: z.boolean(),
+});
+
+export type UpdateSettingsBody = z.infer<typeof SettingsSchema>;
diff --git a/api/src/modules/stats/stats.controller.ts b/api/src/modules/stats/stats.controller.ts
new file mode 100644
index 0000000..520f692
--- /dev/null
+++ b/api/src/modules/stats/stats.controller.ts
@@ -0,0 +1,15 @@
+/**
+ * stats.controller.ts
+ * Lógica do dashboard.
+ */
+
+import type { FastifyReply, FastifyRequest } from 'fastify';
+import { getTicketsStats } from './stats.repository.js';
+
+export async function getStatsHandler(
+ request: FastifyRequest,
+ reply: FastifyReply,
+) {
+ const stats = await getTicketsStats(request.server.db);
+ return reply.send(stats);
+}
diff --git a/api/src/modules/stats/stats.repository.ts b/api/src/modules/stats/stats.repository.ts
new file mode 100644
index 0000000..d5a543d
--- /dev/null
+++ b/api/src/modules/stats/stats.repository.ts
@@ -0,0 +1,41 @@
+/**
+ * stats.repository.ts
+ * Queries para obter os totais agrupados.
+ */
+
+import { sql } from 'drizzle-orm';
+import type { Database } from '@/db/connection.js';
+import { tickets } from '@/db/schema.js';
+
+export async function getTicketsStats(db: Database) {
+ // Uma única query para agrupar as contagens por status
+ const rows = await db
+ .select({
+ status: tickets.status,
+ count: sql<number>`count(*)`,
+ })
+ .from(tickets)
+ .groupBy(tickets.status);
+
+ // Inicializa o mapa com 0
+ const stats = {
+ totalOpen: 0,
+ totalInProgress: 0,
+ totalWaiting: 0,
+ totalResolved: 0,
+ totalClosed: 0,
+ };
+
+ for (const row of rows) {
+ if (row.status === 'open') stats.totalOpen = row.count;
+ if (row.status === 'in_progress') stats.totalInProgress = row.count;
+ if (row.status === 'waiting') stats.totalWaiting = row.count;
+ if (row.status === 'resolved') stats.totalResolved = row.count;
+ if (row.status === 'closed') stats.totalClosed = row.count;
+ }
+
+ return {
+ ...stats,
+ totalPending: stats.totalOpen + stats.totalInProgress,
+ };
+}
diff --git a/api/src/modules/stats/stats.routes.ts b/api/src/modules/stats/stats.routes.ts
new file mode 100644
index 0000000..cf02c08
--- /dev/null
+++ b/api/src/modules/stats/stats.routes.ts
@@ -0,0 +1,22 @@
+/**
+ * stats.routes.ts
+ *
+ * GET /api/v1/stats → retorna agregados para o dashboard
+ */
+
+import type { FastifyInstance } from 'fastify';
+import { statsResponseSchema } from './stats.schema.js';
+import { getStatsHandler } from './stats.controller.js';
+
+async function statsRoutes(fastify: FastifyInstance): Promise<void> {
+ fastify.get('/', {
+ schema: {
+ tags: ['Dashboard'],
+ summary: 'Retorna métricas e agregados dos chamados',
+ },
+ preHandler: [fastify.authenticate],
+ handler: getStatsHandler,
+ });
+}
+
+export default statsRoutes;
diff --git a/api/src/modules/stats/stats.schema.ts b/api/src/modules/stats/stats.schema.ts
new file mode 100644
index 0000000..1616937
--- /dev/null
+++ b/api/src/modules/stats/stats.schema.ts
@@ -0,0 +1,17 @@
+/**
+ * stats.schema.ts
+ * Schemas para o módulo de estatísticas (Dashboard).
+ */
+
+import { z } from 'zod';
+
+export const statsResponseSchema = z.object({
+ totalOpen: z.number(),
+ totalInProgress: z.number(),
+ totalWaiting: z.number(),
+ totalResolved: z.number(),
+ totalClosed: z.number(),
+ totalPending: z.number(), // open + in_progress
+});
+
+export type StatsResponse = z.infer<typeof statsResponseSchema>;
diff --git a/api/src/modules/tickets/tickets.controller.ts b/api/src/modules/tickets/tickets.controller.ts
new file mode 100644
index 0000000..552974a
--- /dev/null
+++ b/api/src/modules/tickets/tickets.controller.ts
@@ -0,0 +1,112 @@
+/**
+ * tickets.controller.ts
+ *
+ * Lógica de negócio do módulo de tickets.
+ * Orquestra o repository, aplica regras e formata respostas.
+ * NÃO acessa o banco diretamente — delega ao repository.
+ */
+
+import type { FastifyReply, FastifyRequest } from 'fastify';
+import { createTicket, countTickets, findManyTickets } from './tickets.repository.js';
+import type { CreateTicketBody, ListTicketsQuery, TicketParams } from './tickets.schema.js';
+import { tickets } from '@/db/schema.js';
+import { eq } from 'drizzle-orm';
+
+// ─── POST /tickets ────────────────────────────────────────────────────────────
+
+export async function createTicketHandler(
+ request: FastifyRequest<{ Body: CreateTicketBody }>,
+ reply: FastifyReply,
+) {
+ const { title, description, priority, categoryId, creatorId } = request.body;
+
+ const ticket = await createTicket(request.server.db, {
+ title,
+ description,
+ priority,
+ categoryId: categoryId ?? null,
+ assigneeId: null, // Chamados nascem sem agente atribuído
+ creatorId,
+ status: 'open',
+ dueAt: null,
+ resolvedAt: null,
+ });
+
+ request.server.events.emit('ticket.created', {
+ id: ticket.id,
+ title: ticket.title,
+ creatorId: ticket.creatorId,
+ });
+
+ return reply.code(201).send(ticket);
+}
+
+// ─── GET /tickets ─────────────────────────────────────────────────────────────
+
+export async function listTicketsHandler(
+ request: FastifyRequest<{ Querystring: ListTicketsQuery }>,
+ reply: FastifyReply,
+) {
+ const filters = request.query;
+ const db = request.server.db;
+
+ // Executa contagem e listagem em paralelo para não dobrar o tempo de resposta
+ const [total, data] = await Promise.all([
+ countTickets(db, filters),
+ findManyTickets(db, filters),
+ ]);
+
+ const totalPages = Math.ceil(total / filters.limit);
+
+ return reply.send({
+ data,
+ meta: {
+ page: filters.page,
+ limit: filters.limit,
+ total,
+ totalPages,
+ },
+ });
+}
+
+// ─── GET /tickets/:id ─────────────────────────────────────────────────────────
+
+export async function getTicketByIdHandler(
+ request: FastifyRequest<{ Params: TicketParams }>,
+ reply: FastifyReply,
+) {
+ const { findTicketById } = await import('./tickets.repository.js');
+ const ticket = await findTicketById(request.server.db, request.params.id);
+
+ if (!ticket) {
+ return reply.notFound(`Ticket #${request.params.id} não encontrado`);
+ }
+
+ return reply.send(ticket);
+}
+
+// ─── DELETE /tickets/:id ──────────────────────────────────────────────────────
+
+export async function deleteTicketHandler(
+ request: FastifyRequest<{ Params: TicketParams }>,
+ reply: FastifyReply,
+) {
+ const db = request.server.db;
+ const { id } = request.params;
+ const callerUser = request.user!;
+
+ const { findTicketById } = await import('./tickets.repository.js');
+ const existingTicket = await findTicketById(db, id);
+
+ if (!existingTicket) {
+ return reply.notFound('Chamado não encontrado.');
+ }
+
+ if (callerUser.role !== 'admin' && existingTicket.creatorId !== callerUser.id) {
+ return reply.forbidden('Você não tem permissão para excluir este chamado.');
+ }
+
+ await db.delete(tickets).where(eq(tickets.id, id));
+
+ return reply.code(204).send();
+}
diff --git a/api/src/modules/tickets/tickets.repository.ts b/api/src/modules/tickets/tickets.repository.ts
new file mode 100644
index 0000000..0c34abe
--- /dev/null
+++ b/api/src/modules/tickets/tickets.repository.ts
@@ -0,0 +1,145 @@
+/**
+ * tickets.repository.ts
+ *
+ * Camada de acesso a dados do módulo de tickets.
+ * É a ÚNICA camada que importa e usa o Drizzle diretamente.
+ *
+ * Recebe `db` como parâmetro em vez de importar o singleton,
+ * o que facilita testes unitários com banco em memória.
+ */
+
+import { eq, and, count, desc, type SQL } from 'drizzle-orm';
+import type { Database } from '@/db/connection.js';
+import { tickets, users, categories, type NewTicket } from '@/db/schema.js';
+import { alias } from 'drizzle-orm/sqlite-core';
+import type { ListTicketsQuery } from './tickets.schema.js';
+
+// ─── Criação ──────────────────────────────────────────────────────────────────
+
+export async function createTicket(
+ db: Database,
+ data: NewTicket,
+) {
+ const [ticket] = await db
+ .insert(tickets)
+ .values({
+ ...data,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ })
+ .returning();
+
+ return ticket;
+}
+
+// ─── Listagem com filtros e paginação ─────────────────────────────────────────
+
+export async function findManyTickets(
+ db: Database,
+ filters: ListTicketsQuery,
+) {
+ const { status, priority, assigneeId, creatorId, categoryId, page, limit } = filters;
+ const offset = (page - 1) * limit;
+
+ // Constrói array de condições dinamicamente
+ // Apenas condições com valor definido são incluídas no WHERE
+ const conditions: SQL[] = [];
+ if (status) conditions.push(eq(tickets.status, status));
+ if (priority) conditions.push(eq(tickets.priority, priority));
+ if (assigneeId) conditions.push(eq(tickets.assigneeId, assigneeId));
+ if (creatorId) conditions.push(eq(tickets.creatorId, creatorId));
+ if (categoryId) conditions.push(eq(tickets.categoryId, categoryId));
+
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
+
+ const creatorAlias = alias(users, 'creator');
+ const assigneeAlias = alias(users, 'assignee');
+
+ const rows = await db
+ .select({
+ id: tickets.id,
+ title: tickets.title,
+ description: tickets.description,
+ status: tickets.status,
+ priority: tickets.priority,
+ creatorId: tickets.creatorId,
+ assigneeId: tickets.assigneeId,
+ categoryId: tickets.categoryId,
+ dueAt: tickets.dueAt,
+ resolvedAt: tickets.resolvedAt,
+ createdAt: tickets.createdAt,
+ updatedAt: tickets.updatedAt,
+ creatorName: creatorAlias.name,
+ assigneeName: assigneeAlias.name,
+ categoryName: categories.name,
+ })
+ .from(tickets)
+ .leftJoin(creatorAlias, eq(tickets.creatorId, creatorAlias.id))
+ .leftJoin(assigneeAlias, eq(tickets.assigneeId, assigneeAlias.id))
+ .leftJoin(categories, eq(tickets.categoryId, categories.id))
+ .where(where)
+ .orderBy(desc(tickets.createdAt)) // Mais recentes primeiro
+ .limit(limit)
+ .offset(offset);
+
+ return rows;
+}
+
+// ─── Contagem (para meta de paginação) ───────────────────────────────────────
+
+export async function countTickets(
+ db: Database,
+ filters: Omit<ListTicketsQuery, 'page' | 'limit'>,
+) {
+ const { status, priority, assigneeId, creatorId, categoryId } = filters;
+
+ const conditions: SQL[] = [];
+ if (status) conditions.push(eq(tickets.status, status));
+ if (priority) conditions.push(eq(tickets.priority, priority));
+ if (assigneeId) conditions.push(eq(tickets.assigneeId, assigneeId));
+ if (creatorId) conditions.push(eq(tickets.creatorId, creatorId));
+ if (categoryId) conditions.push(eq(tickets.categoryId, categoryId));
+
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
+
+ const [result] = await db
+ .select({ total: count() })
+ .from(tickets)
+ .where(where);
+
+ return result?.total ?? 0;
+}
+
+// ─── Busca por ID ─────────────────────────────────────────────────────────────
+
+export async function findTicketById(db: Database, id: number) {
+ const creatorAlias = alias(users, 'creator');
+ const assigneeAlias = alias(users, 'assignee');
+
+ const [ticket] = await db
+ .select({
+ id: tickets.id,
+ title: tickets.title,
+ description: tickets.description,
+ status: tickets.status,
+ priority: tickets.priority,
+ creatorId: tickets.creatorId,
+ assigneeId: tickets.assigneeId,
+ categoryId: tickets.categoryId,
+ dueAt: tickets.dueAt,
+ resolvedAt: tickets.resolvedAt,
+ createdAt: tickets.createdAt,
+ updatedAt: tickets.updatedAt,
+ creatorName: creatorAlias.name,
+ assigneeName: assigneeAlias.name,
+ categoryName: categories.name,
+ })
+ .from(tickets)
+ .leftJoin(creatorAlias, eq(tickets.creatorId, creatorAlias.id))
+ .leftJoin(assigneeAlias, eq(tickets.assigneeId, assigneeAlias.id))
+ .leftJoin(categories, eq(tickets.categoryId, categories.id))
+ .where(eq(tickets.id, id))
+ .limit(1);
+
+ return ticket ?? null;
+}
diff --git a/api/src/modules/tickets/tickets.routes.ts b/api/src/modules/tickets/tickets.routes.ts
new file mode 100644
index 0000000..69c04a4
--- /dev/null
+++ b/api/src/modules/tickets/tickets.routes.ts
@@ -0,0 +1,75 @@
+/**
+ * tickets.routes.ts
+ *
+ * Plugin Fastify com todas as rotas do módulo de tickets.
+ * Registrado no server.ts com prefixo '/api/v1/tickets'.
+ *
+ * Rotas disponíveis:
+ * POST /api/v1/tickets → abre novo chamado
+ * GET /api/v1/tickets → lista chamados (com filtros e paginação)
+ * GET /api/v1/tickets/:id → busca chamado por ID
+ */
+
+import type { FastifyInstance } from 'fastify';
+import {
+ createTicketBodySchema,
+ listTicketsQuerySchema,
+ ticketParamsSchema,
+} from './tickets.schema.js';
+import {
+ createTicketHandler,
+ listTicketsHandler,
+ getTicketByIdHandler,
+ deleteTicketHandler,
+} from './tickets.controller.js';
+
+async function ticketsRoutes(fastify: FastifyInstance): Promise<void> {
+
+ // ── POST /api/v1/tickets ──────────────────────────────────────────────────
+ fastify.post('/', {
+ schema: {
+ tags: ['Tickets'],
+ summary: 'Cria um novo chamado',
+ description: 'Cria um novo chamado, definindo o criador como o usuário logado.',
+ body: createTicketBodySchema,
+ },
+ preHandler: [fastify.authenticate],
+ handler: createTicketHandler,
+ });
+
+ // ── GET /api/v1/tickets ───────────────────────────────────────────────────
+ fastify.get('/', {
+ schema: {
+ tags: ['Tickets'],
+ summary: 'Lista os chamados',
+ description: 'Retorna a lista de chamados, suportando filtros e paginação.',
+ querystring: listTicketsQuerySchema,
+ },
+ preHandler: [fastify.authenticate],
+ handler: listTicketsHandler,
+ });
+
+ // ── GET /api/v1/tickets/:id ───────────────────────────────────────────────
+ fastify.get('/:id', {
+ schema: {
+ tags: ['Tickets'],
+ summary: 'Busca um chamado pelo ID',
+ params: ticketParamsSchema,
+ },
+ preHandler: [fastify.authenticate],
+ handler: getTicketByIdHandler,
+ });
+
+ // ── DELETE /tickets/:id ────────────────────────────────────────────────────
+ fastify.delete('/:id', {
+ schema: {
+ tags: ['Tickets'],
+ summary: 'Exclui um chamado pelo ID',
+ params: ticketParamsSchema,
+ },
+ preHandler: [fastify.authenticate],
+ handler: deleteTicketHandler,
+ });
+}
+
+export default ticketsRoutes;
diff --git a/api/src/modules/tickets/tickets.schema.ts b/api/src/modules/tickets/tickets.schema.ts
new file mode 100644
index 0000000..43d94cf
--- /dev/null
+++ b/api/src/modules/tickets/tickets.schema.ts
@@ -0,0 +1,89 @@
+/**
+ * tickets.schema.ts
+ *
+ * Schemas Zod para validação de entrada e saída do módulo de tickets.
+ * Usados tanto pelo Fastify (validação automática de requests)
+ * quanto pelo TypeScript (inferência de tipos).
+ */
+
+import { z } from 'zod';
+import { TICKET_PRIORITIES, TICKET_STATUSES } from '@/db/schema.js';
+
+// ─── Criação de Ticket ────────────────────────────────────────────────────────
+
+export const createTicketBodySchema = z.object({
+ title: z
+ .string({ required_error: 'Título é obrigatório' })
+ .min(5, 'Título deve ter no mínimo 5 caracteres')
+ .max(150, 'Título deve ter no máximo 150 caracteres')
+ .trim(),
+
+ description: z
+ .string({ required_error: 'Descrição é obrigatória' })
+ .min(10, 'Descrição deve ter no mínimo 10 caracteres')
+ .trim(),
+
+ priority: z.enum(TICKET_PRIORITIES).default('medium'),
+
+ categoryId: z.number().int().positive().optional(),
+
+ // Em uma implementação real, o creatorId viria do token JWT.
+ // Por ora, aceita no body para facilitar o desenvolvimento iterativo.
+ creatorId: z
+ .number({ required_error: 'creatorId é obrigatório' })
+ .int()
+ .positive(),
+});
+
+export type CreateTicketBody = z.infer<typeof createTicketBodySchema>;
+
+// ─── Listagem de Tickets (filtros + paginação) ────────────────────────────────
+
+export const listTicketsQuerySchema = z.object({
+ status: z.enum(TICKET_STATUSES).optional(),
+ priority: z.enum(TICKET_PRIORITIES).optional(),
+ assigneeId: z.coerce.number().int().positive().optional(),
+ creatorId: z.coerce.number().int().positive().optional(),
+ categoryId: z.coerce.number().int().positive().optional(),
+
+ // Paginação
+ page: z.coerce.number().int().min(1).default(1),
+ limit: z.coerce.number().int().min(1).max(100).default(20),
+});
+
+export type ListTicketsQuery = z.infer<typeof listTicketsQuerySchema>;
+
+// ─── Parâmetros de rota ───────────────────────────────────────────────────────
+
+export const ticketParamsSchema = z.object({
+ id: z.coerce.number().int().positive(),
+});
+
+export type TicketParams = z.infer<typeof ticketParamsSchema>;
+
+// ─── Shape de resposta ────────────────────────────────────────────────────────
+
+export const ticketResponseSchema = z.object({
+ id: z.number(),
+ title: z.string(),
+ description: z.string(),
+ status: z.enum(TICKET_STATUSES),
+ priority: z.enum(TICKET_PRIORITIES),
+ creatorId: z.number(),
+ assigneeId: z.number().nullable(),
+ categoryId: z.number().nullable(),
+ dueAt: z.string().nullable(),
+ resolvedAt: z.string().nullable(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+});
+
+export const listTicketsResponseSchema = z.object({
+ data: z.array(ticketResponseSchema),
+ meta: z.object({
+ page: z.number(),
+ limit: z.number(),
+ total: z.number(),
+ totalPages: z.number(),
+ }),
+});
diff --git a/api/src/modules/users/users.controller.ts b/api/src/modules/users/users.controller.ts
new file mode 100644
index 0000000..c3c06fd
--- /dev/null
+++ b/api/src/modules/users/users.controller.ts
@@ -0,0 +1,129 @@
+import type { FastifyRequest, FastifyReply } from 'fastify';
+import bcrypt from 'bcryptjs';
+import { users } from '@/db/schema.js';
+import { eq, and, ne } from 'drizzle-orm';
+import type { CreateUserByAdminBody, UpdateUserBody } from './users.schema.js';
+
+export async function listUsersHandler(request: FastifyRequest, reply: FastifyReply) {
+ const db = request.server.db;
+ const allUsers = await db.query.users.findMany({
+ columns: {
+ password: false, // Don't return passwords
+ },
+ orderBy: (users, { desc }) => [desc(users.createdAt)],
+ });
+
+ return reply.send(allUsers);
+}
+
+export async function createUserHandler(
+ request: FastifyRequest<{ Body: CreateUserByAdminBody }>,
+ reply: FastifyReply,
+) {
+ const db = request.server.db;
+ const { name, email, password, role } = request.body;
+
+ // Verifica se email já existe
+ const existing = await db.query.users.findFirst({
+ where: eq(users.email, email),
+ });
+
+ if (existing) {
+ return reply.conflict('Este e-mail já está cadastrado.');
+ }
+
+ const hashedPassword = await bcrypt.hash(password, 12);
+
+ const [newUser] = await db.insert(users).values({
+ name,
+ email,
+ password: hashedPassword,
+ role,
+ isActive: true,
+ }).returning({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ role: users.role,
+ isActive: users.isActive,
+ createdAt: users.createdAt,
+ });
+
+ return reply.code(201).send(newUser);
+}
+
+export async function updateUserHandler(
+ request: FastifyRequest<{ Params: { id: number }; Body: UpdateUserBody }>,
+ reply: FastifyReply,
+) {
+ const db = request.server.db;
+ const { id } = request.params;
+ const { name, email, password, role, isActive } = request.body;
+
+ const existingUser = await db.query.users.findFirst({
+ where: eq(users.id, id),
+ });
+
+ if (!existingUser) {
+ return reply.notFound('Usuário não encontrado.');
+ }
+
+ if (email && email !== existingUser.email) {
+ const existingEmail = await db.query.users.findFirst({
+ where: and(eq(users.email, email), ne(users.id, id)),
+ });
+ if (existingEmail) {
+ return reply.conflict('Este e-mail já está em uso por outro usuário.');
+ }
+ }
+
+ const updates: Partial<typeof users.$inferInsert> = {
+ name,
+ email,
+ role,
+ isActive,
+ updatedAt: new Date().toISOString(),
+ };
+
+ if (password && password.trim() !== '') {
+ updates.password = await bcrypt.hash(password, 12);
+ }
+
+ const [updatedUser] = await db.update(users)
+ .set(updates)
+ .where(eq(users.id, id))
+ .returning({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ role: users.role,
+ isActive: users.isActive,
+ createdAt: users.createdAt,
+ });
+
+ return reply.send(updatedUser);
+}
+
+export async function deleteUserHandler(
+ request: FastifyRequest<{ Params: { id: number } }>,
+ reply: FastifyReply,
+) {
+ const db = request.server.db;
+ const { id } = request.params;
+
+ const existingUser = await db.query.users.findFirst({
+ where: eq(users.id, id),
+ });
+
+ if (!existingUser) {
+ return reply.notFound('Usuário não encontrado.');
+ }
+
+ if (id === request.user?.id) {
+ return reply.badRequest('Você não pode excluir a própria conta.');
+ }
+
+ await db.delete(users).where(eq(users.id, id));
+
+ return reply.code(204).send();
+}
diff --git a/api/src/modules/users/users.routes.ts b/api/src/modules/users/users.routes.ts
new file mode 100644
index 0000000..c7f1846
--- /dev/null
+++ b/api/src/modules/users/users.routes.ts
@@ -0,0 +1,56 @@
+import type { FastifyInstance } from 'fastify';
+import { createUserByAdminBodySchema, updateUserBodySchema, userIdParamsSchema } from './users.schema.js';
+import { listUsersHandler, createUserHandler, updateUserHandler, deleteUserHandler } from './users.controller.js';
+
+export default async function usersRoutes(fastify: FastifyInstance) {
+ // Middleware customizado para garantir que apenas admins possam acessar
+ const requireAdmin = async (request: any, reply: any) => {
+ if (request.user?.role !== 'admin') {
+ return reply.forbidden('Apenas administradores podem gerenciar usuários.');
+ }
+ };
+
+ // ── GET /users ─────────────────────────────────────────────────────────────
+ fastify.get('/', {
+ schema: {
+ tags: ['Users'],
+ summary: 'Lista todos os usuários (admin)',
+ },
+ preHandler: [fastify.authenticate, requireAdmin],
+ handler: listUsersHandler,
+ });
+
+ // ── POST /users ────────────────────────────────────────────────────────────
+ fastify.post('/', {
+ schema: {
+ tags: ['Users'],
+ summary: 'Cria um novo usuário (admin)',
+ body: createUserByAdminBodySchema,
+ },
+ preHandler: [fastify.authenticate, requireAdmin],
+ handler: createUserHandler,
+ });
+
+ // ── PATCH /users/:id ───────────────────────────────────────────────────────
+ fastify.patch('/:id', {
+ schema: {
+ tags: ['Users'],
+ summary: 'Atualiza um usuário (admin)',
+ params: userIdParamsSchema,
+ body: updateUserBodySchema,
+ },
+ preHandler: [fastify.authenticate, requireAdmin],
+ handler: updateUserHandler,
+ });
+
+ // ── DELETE /users/:id ──────────────────────────────────────────────────────
+ fastify.delete('/:id', {
+ schema: {
+ tags: ['Users'],
+ summary: 'Exclui um usuário (admin)',
+ params: userIdParamsSchema,
+ },
+ preHandler: [fastify.authenticate, requireAdmin],
+ handler: deleteUserHandler,
+ });
+}
diff --git a/api/src/modules/users/users.schema.ts b/api/src/modules/users/users.schema.ts
new file mode 100644
index 0000000..ff3f459
--- /dev/null
+++ b/api/src/modules/users/users.schema.ts
@@ -0,0 +1,25 @@
+import { z } from 'zod';
+import { USER_ROLES } from '@/db/schema.js';
+
+export const createUserByAdminBodySchema = z.object({
+ name: z.string().min(2).max(100).trim(),
+ email: z.string().email().toLowerCase().trim(),
+ password: z.string().min(8).max(100),
+ role: z.enum(USER_ROLES),
+});
+
+export type CreateUserByAdminBody = z.infer<typeof createUserByAdminBodySchema>;
+
+export const updateUserBodySchema = z.object({
+ name: z.string().min(2).max(100).trim().optional(),
+ email: z.string().email().toLowerCase().trim().optional(),
+ password: z.string().min(8).max(100).optional().or(z.literal('')),
+ role: z.enum(USER_ROLES).optional(),
+ isActive: z.boolean().optional(),
+});
+
+export type UpdateUserBody = z.infer<typeof updateUserBodySchema>;
+
+export const userIdParamsSchema = z.object({
+ id: z.coerce.number().positive(),
+});