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/tickets | |
| download | helpdesk-ef55d9bb24430610b60c1b77821307cd72c08c55.tar.gz helpdesk-ef55d9bb24430610b60c1b77821307cd72c08c55.zip | |
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'api/src/modules/tickets')
| -rw-r--r-- | api/src/modules/tickets/tickets.controller.ts | 112 | ||||
| -rw-r--r-- | api/src/modules/tickets/tickets.repository.ts | 145 | ||||
| -rw-r--r-- | api/src/modules/tickets/tickets.routes.ts | 75 | ||||
| -rw-r--r-- | api/src/modules/tickets/tickets.schema.ts | 89 |
4 files changed, 421 insertions, 0 deletions
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(), + }), +}); |