From ef55d9bb24430610b60c1b77821307cd72c08c55 Mon Sep 17 00:00:00 2001 From: zwlucas Date: Sat, 25 Jul 2026 18:23:29 -0300 Subject: feat: upload of the project Signed-off-by: zwlucas --- .../interactions/interactions.controller.ts | 190 +++++++++++++++++++++ .../interactions/interactions.repository.ts | 135 +++++++++++++++ .../modules/interactions/interactions.routes.ts | 86 ++++++++++ .../modules/interactions/interactions.schema.ts | 101 +++++++++++ 4 files changed, 512 insertions(+) create mode 100644 api/src/modules/interactions/interactions.controller.ts create mode 100644 api/src/modules/interactions/interactions.repository.ts create mode 100644 api/src/modules/interactions/interactions.routes.ts create mode 100644 api/src/modules/interactions/interactions.schema.ts (limited to 'api/src/modules/interactions') 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 { + + // ── 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; +export type CommentIdParams = z.infer; + +// ─── 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; + +// ─── Atribuição de agente ───────────────────────────────────────────────────── + +export const assignTicketBodySchema = z.object({ + assigneeId: z + .number({ required_error: 'assigneeId é obrigatório' }) + .int() + .positive(), +}); + +export type AssignTicketBody = z.infer; + +// ─── 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; + +// ─── 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; + +// ─── 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; -- cgit v1.2.3