diff options
| author | zwlucas <lucas.fariamo08@gmail.com> | 2026-07-25 21:23:29 +0000 |
|---|---|---|
| committer | zwlucas <lucas.fariamo08@gmail.com> | 2026-07-25 21:23:29 +0000 |
| commit | ef55d9bb24430610b60c1b77821307cd72c08c55 (patch) | |
| tree | e6935a810b34370b0a4c821e55f6397aac50c832 /api/src/modules | |
| download | helpdesk-master.tar.gz helpdesk-master.zip | |
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'api/src/modules')
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(), +}); |