aboutsummaryrefslogtreecommitdiff
path: root/api/src/modules/interactions
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/interactions
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/interactions')
-rw-r--r--api/src/modules/interactions/interactions.controller.ts190
-rw-r--r--api/src/modules/interactions/interactions.repository.ts135
-rw-r--r--api/src/modules/interactions/interactions.routes.ts86
-rw-r--r--api/src/modules/interactions/interactions.schema.ts101
4 files changed, 512 insertions, 0 deletions
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>;