aboutsummaryrefslogtreecommitdiff
path: root/api/src/modules/categories
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/categories
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/categories')
-rw-r--r--api/src/modules/categories/categories.controller.ts127
-rw-r--r--api/src/modules/categories/categories.repository.ts80
-rw-r--r--api/src/modules/categories/categories.routes.ts92
-rw-r--r--api/src/modules/categories/categories.schema.ts76
4 files changed, 375 insertions, 0 deletions
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>;