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 --- .../modules/categories/categories.controller.ts | 127 +++++++++++++++++++++ .../modules/categories/categories.repository.ts | 80 +++++++++++++ api/src/modules/categories/categories.routes.ts | 92 +++++++++++++++ api/src/modules/categories/categories.schema.ts | 76 ++++++++++++ 4 files changed, 375 insertions(+) create mode 100644 api/src/modules/categories/categories.controller.ts create mode 100644 api/src/modules/categories/categories.repository.ts create mode 100644 api/src/modules/categories/categories.routes.ts create mode 100644 api/src/modules/categories/categories.schema.ts (limited to 'api/src/modules/categories') 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, +) { + 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>, +) { + 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 { + + // ── 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; + +// ─── 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; + +// ─── Parâmetros de rota ─────────────────────────────────────────────────────── + +export const categoryParamsSchema = z.object({ + id: z.coerce.number().int().positive(), +}); + +export type CategoryParams = z.infer; + +// ─── 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; -- cgit v1.2.3