aboutsummaryrefslogtreecommitdiff
path: root/api/src/modules/tickets
diff options
context:
space:
mode:
authorzwlucas <lucas.fariamo08@gmail.com>2026-07-25 21:23:29 +0000
committerzwlucas <lucas.fariamo08@gmail.com>2026-07-25 21:23:29 +0000
commitef55d9bb24430610b60c1b77821307cd72c08c55 (patch)
treee6935a810b34370b0a4c821e55f6397aac50c832 /api/src/modules/tickets
downloadhelpdesk-master.tar.gz
helpdesk-master.zip
feat: upload of the projectHEADmaster
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'api/src/modules/tickets')
-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
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(),
+ }),
+});