diff options
| author | zwlucas <lucas.fariamo08@gmail.com> | 2026-07-25 21:23:29 +0000 |
|---|---|---|
| committer | zwlucas <lucas.fariamo08@gmail.com> | 2026-07-25 21:23:29 +0000 |
| commit | ef55d9bb24430610b60c1b77821307cd72c08c55 (patch) | |
| tree | e6935a810b34370b0a4c821e55f6397aac50c832 /api | |
| download | helpdesk-master.tar.gz helpdesk-master.zip | |
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'api')
46 files changed, 3883 insertions, 0 deletions
diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..466b1ac --- /dev/null +++ b/api/.env.example @@ -0,0 +1,23 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Helpdesk API — Variáveis de Ambiente +# Copie este arquivo para .env e ajuste os valores: +# cp .env.example .env +# ───────────────────────────────────────────────────────────────────────────── + +# Banco de Dados SQLite +# Caminho relativo à raiz do pacote api/ +DATABASE_URL=file:./helpdesk.db + +# Servidor HTTP +PORT=3000 +HOST=0.0.0.0 + +# Ambiente de execução +# Valores: development | production | test +NODE_ENV=development + +# Autenticação JWT +# Gere um valor seguro com: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" +JWT_SECRET=troque_por_um_segredo_forte_em_producao +JWT_EXPIRES_IN=7d + diff --git a/api/drizzle.config.ts b/api/drizzle.config.ts new file mode 100644 index 0000000..2a0ab52 --- /dev/null +++ b/api/drizzle.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'drizzle-kit'; + +/** + * drizzle.config.ts + * + * Configuração do drizzle-kit — usado pelos comandos: + * pnpm db:generate → lê o schema e gera arquivos SQL de migração + * pnpm db:migrate → aplica as migrações pendentes no banco + * pnpm db:studio → abre o Drizzle Studio (GUI web para o banco) + */ +export default defineConfig({ + // Dialeto do banco de dados + dialect: 'sqlite', + + // Fonte de verdade: onde está o schema Drizzle + schema: './src/db/schema.ts', + + // Onde os arquivos SQL de migração serão gerados e lidos + out: './src/db/migrations', + + dbCredentials: { + // Lê a URL do banco do arquivo .env + // Em produção, garanta que DATABASE_URL está definido no ambiente do servidor + url: process.env['DATABASE_URL'] ?? 'file:./helpdesk.db', + }, + + // Exibe SQL gerado no terminal durante `db:generate` (útil para code review) + verbose: true, + + // Aborta se detectar mudanças destrutivas acidentais (DROP TABLE, DROP COLUMN) + strict: true, +}); diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..0a11511 --- /dev/null +++ b/api/package.json @@ -0,0 +1,40 @@ +{ + "name": "@helpdesk/api", + "version": "1.0.0", + "description": "Backend do Sistema de Chamados de TI — Fastify + Drizzle + SQLite", + "private": true, + "type": "module", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc --project tsconfig.json", + "start": "node dist/server.js", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@fastify/cors": "^9.0.1", + "@fastify/jwt": "^8.0.1", + "@fastify/sensible": "^5.6.0", + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.1.2", + "dotenv": "^16.4.5", + "drizzle-orm": "^0.31.4", + "fastify": "^4.28.1", + "fastify-plugin": "^4.5.1", + "fastify-type-provider-zod": "1.1.9", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/better-sqlite3": "^7.6.11", + "@types/node": "^20.14.11", + "drizzle-kit": "^0.22.8", + "pino-pretty": "^11.2.1", + "tsx": "^4.16.2", + "typescript": "^5.5.3" + } +} diff --git a/api/src/db/connection.ts b/api/src/db/connection.ts new file mode 100644 index 0000000..2982336 --- /dev/null +++ b/api/src/db/connection.ts @@ -0,0 +1,37 @@ +/** + * connection.ts + * + * Singleton de conexão com o banco de dados. + * + * Exporta uma única instância do Drizzle ORM configurada com: + * - better-sqlite3 como driver síncrono + * - WAL mode para concorrência + * - Foreign keys habilitadas + * - Schema tipado para autocompletar queries + * + * Uso em qualquer módulo da aplicação: + * import { db } from '@/db/connection' + */ + +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import * as schema from './schema.js'; + +const DATABASE_URL = process.env['DATABASE_URL'] ?? 'file:./helpdesk.db'; +const dbPath = DATABASE_URL.replace(/^file:/, ''); + +// ─── Criação do cliente SQLite ──────────────────────────────────────────────── +const sqlite = new Database(dbPath); + +// WAL mode: leituras não bloqueiam escritas (essencial para servidor web) +sqlite.pragma('journal_mode = WAL'); + +// Ativa integridade referencial (desativada por padrão no SQLite) +sqlite.pragma('foreign_keys = ON'); + +// ─── Instância Drizzle com schema completo ──────────────────────────────────── +// Passar o schema habilita db.query.tickets.findMany() com joins automáticos +export const db = drizzle(sqlite, { schema }); + +// Exporta o tipo para uso em funções que recebem `db` como parâmetro +export type Database = typeof db; diff --git a/api/src/db/migrate.ts b/api/src/db/migrate.ts new file mode 100644 index 0000000..7964ef6 --- /dev/null +++ b/api/src/db/migrate.ts @@ -0,0 +1,56 @@ +/** + * migrate.ts + * + * Script responsável por aplicar todas as migrations pendentes no banco SQLite. + * + * Pode ser executado de duas formas: + * 1. Manual (CLI): pnpm db:migrate + * 2. Programática: importado e chamado no boot do servidor (server.ts) + */ + +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Resolve o diretório correto em módulos ESM (não existe __dirname por padrão) +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MIGRATIONS_DIR = path.join(__dirname, 'migrations'); +const DATABASE_URL = process.env['DATABASE_URL'] ?? 'file:./helpdesk.db'; + +// Drizzle espera o caminho sem o prefixo "file:" +const dbPath = DATABASE_URL.replace(/^file:/, ''); + +export async function runMigrations(): Promise<void> { + console.log('🗄️ Iniciando migrações do banco de dados...'); + console.log(` Banco : ${dbPath}`); + console.log(` Origem : ${MIGRATIONS_DIR}`); + + // better-sqlite3 opera de forma síncrona (sem Promises) + const sqlite = new Database(dbPath); + + // Otimizações de performance recomendadas para SQLite + sqlite.pragma('journal_mode = WAL'); // Write-Ahead Logging: leituras não bloqueiam escritas + sqlite.pragma('foreign_keys = ON'); // Ativa integridade referencial (desativada por padrão no SQLite) + + const db = drizzle(sqlite); + + // Aplica todas as migrations ainda não registradas na tabela __drizzle_migrations + migrate(db, { migrationsFolder: MIGRATIONS_DIR }); + + sqlite.close(); + console.log('✅ Migrações aplicadas com sucesso.'); +} + +// ─── Execução direta (CLI) ──────────────────────────────────────────────────── +// Roda apenas quando o arquivo é chamado diretamente (pnpm db:migrate) +// e não quando é importado como módulo pelo server.ts +const isMain = process.argv[1] === fileURLToPath(import.meta.url); + +if (isMain) { + runMigrations().catch((err) => { + console.error('❌ Falha ao aplicar migrações:', err); + process.exit(1); + }); +} diff --git a/api/src/db/migrations/0000_huge_luke_cage.sql b/api/src/db/migrations/0000_huge_luke_cage.sql new file mode 100644 index 0000000..1abb4d2 --- /dev/null +++ b/api/src/db/migrations/0000_huge_luke_cage.sql @@ -0,0 +1,61 @@ +CREATE TABLE `categories` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `slug` text NOT NULL, + `description` text, + `created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + `updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); +--> statement-breakpoint +CREATE TABLE `comments` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `content` text NOT NULL, + `is_internal` integer DEFAULT false NOT NULL, + `ticket_id` integer NOT NULL, + `author_id` integer NOT NULL, + `created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + `updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + FOREIGN KEY (`ticket_id`) REFERENCES `tickets`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE restrict +); +--> statement-breakpoint +CREATE TABLE `tickets` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `title` text NOT NULL, + `description` text NOT NULL, + `status` text DEFAULT 'open' NOT NULL, + `priority` text DEFAULT 'medium' NOT NULL, + `creator_id` integer NOT NULL, + `assignee_id` integer, + `category_id` integer, + `due_at` text, + `resolved_at` text, + `created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + `updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE restrict, + FOREIGN KEY (`assignee_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE TABLE `users` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `email` text NOT NULL, + `password` text NOT NULL, + `role` text DEFAULT 'requester' NOT NULL, + `is_active` integer DEFAULT true NOT NULL, + `created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + `updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `categories_slug_unique_idx` ON `categories` (`slug`);--> statement-breakpoint +CREATE INDEX `comments_ticket_id_idx` ON `comments` (`ticket_id`);--> statement-breakpoint +CREATE INDEX `comments_author_id_idx` ON `comments` (`author_id`);--> statement-breakpoint +CREATE INDEX `tickets_status_idx` ON `tickets` (`status`);--> statement-breakpoint +CREATE INDEX `tickets_priority_idx` ON `tickets` (`priority`);--> statement-breakpoint +CREATE INDEX `tickets_assignee_id_idx` ON `tickets` (`assignee_id`);--> statement-breakpoint +CREATE INDEX `tickets_creator_id_idx` ON `tickets` (`creator_id`);--> statement-breakpoint +CREATE INDEX `tickets_category_id_idx` ON `tickets` (`category_id`);--> statement-breakpoint +CREATE INDEX `tickets_status_assignee_idx` ON `tickets` (`status`,`assignee_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `users_email_unique_idx` ON `users` (`email`);--> statement-breakpoint +CREATE INDEX `users_role_idx` ON `users` (`role`);
\ No newline at end of file diff --git a/api/src/db/migrations/0001_ambitious_emma_frost.sql b/api/src/db/migrations/0001_ambitious_emma_frost.sql new file mode 100644 index 0000000..c4256aa --- /dev/null +++ b/api/src/db/migrations/0001_ambitious_emma_frost.sql @@ -0,0 +1,6 @@ +CREATE TABLE `system_settings` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `allow_requester_chat` integer DEFAULT true NOT NULL, + `created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + `updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); diff --git a/api/src/db/migrations/meta/0000_snapshot.json b/api/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..8cdfb93 --- /dev/null +++ b/api/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,446 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6d22de90-25a5-4565-8b37-8d1d479c2dc6", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "categories_slug_unique_idx": { + "name": "categories_slug_unique_idx", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_internal": { + "name": "is_internal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "comments_ticket_id_idx": { + "name": "comments_ticket_id_idx", + "columns": [ + "ticket_id" + ], + "isUnique": false + }, + "comments_author_id_idx": { + "name": "comments_author_id_idx", + "columns": [ + "author_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comments_ticket_id_tickets_id_fk": { + "name": "comments_ticket_id_tickets_id_fk", + "tableFrom": "comments", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_author_id_users_id_fk": { + "name": "comments_author_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tickets": { + "name": "tickets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "creator_id": { + "name": "creator_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_at": { + "name": "due_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tickets_priority_idx": { + "name": "tickets_priority_idx", + "columns": [ + "priority" + ], + "isUnique": false + }, + "tickets_assignee_id_idx": { + "name": "tickets_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tickets_creator_id_idx": { + "name": "tickets_creator_id_idx", + "columns": [ + "creator_id" + ], + "isUnique": false + }, + "tickets_category_id_idx": { + "name": "tickets_category_id_idx", + "columns": [ + "category_id" + ], + "isUnique": false + }, + "tickets_status_assignee_idx": { + "name": "tickets_status_assignee_idx", + "columns": [ + "status", + "assignee_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tickets_creator_id_users_id_fk": { + "name": "tickets_creator_id_users_id_fk", + "tableFrom": "tickets", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "tickets_assignee_id_users_id_fk": { + "name": "tickets_assignee_id_users_id_fk", + "tableFrom": "tickets", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tickets_category_id_categories_id_fk": { + "name": "tickets_category_id_categories_id_fk", + "tableFrom": "tickets", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requester'" + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "users_email_unique_idx": { + "name": "users_email_unique_idx", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + "role" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +}
\ No newline at end of file diff --git a/api/src/db/migrations/meta/0001_snapshot.json b/api/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..9fd41f7 --- /dev/null +++ b/api/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,486 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e3832373-0ea8-46a1-8480-091761f1a94a", + "prevId": "6d22de90-25a5-4565-8b37-8d1d479c2dc6", + "tables": { + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "categories_slug_unique_idx": { + "name": "categories_slug_unique_idx", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_internal": { + "name": "is_internal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "comments_ticket_id_idx": { + "name": "comments_ticket_id_idx", + "columns": [ + "ticket_id" + ], + "isUnique": false + }, + "comments_author_id_idx": { + "name": "comments_author_id_idx", + "columns": [ + "author_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comments_ticket_id_tickets_id_fk": { + "name": "comments_ticket_id_tickets_id_fk", + "tableFrom": "comments", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_author_id_users_id_fk": { + "name": "comments_author_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "system_settings": { + "name": "system_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "allow_requester_chat": { + "name": "allow_requester_chat", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tickets": { + "name": "tickets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "creator_id": { + "name": "creator_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_at": { + "name": "due_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tickets_priority_idx": { + "name": "tickets_priority_idx", + "columns": [ + "priority" + ], + "isUnique": false + }, + "tickets_assignee_id_idx": { + "name": "tickets_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tickets_creator_id_idx": { + "name": "tickets_creator_id_idx", + "columns": [ + "creator_id" + ], + "isUnique": false + }, + "tickets_category_id_idx": { + "name": "tickets_category_id_idx", + "columns": [ + "category_id" + ], + "isUnique": false + }, + "tickets_status_assignee_idx": { + "name": "tickets_status_assignee_idx", + "columns": [ + "status", + "assignee_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tickets_creator_id_users_id_fk": { + "name": "tickets_creator_id_users_id_fk", + "tableFrom": "tickets", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "tickets_assignee_id_users_id_fk": { + "name": "tickets_assignee_id_users_id_fk", + "tableFrom": "tickets", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tickets_category_id_categories_id_fk": { + "name": "tickets_category_id_categories_id_fk", + "tableFrom": "tickets", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requester'" + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "users_email_unique_idx": { + "name": "users_email_unique_idx", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + "role" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +}
\ No newline at end of file diff --git a/api/src/db/migrations/meta/_journal.json b/api/src/db/migrations/meta/_journal.json new file mode 100644 index 0000000..b3ea0c0 --- /dev/null +++ b/api/src/db/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1784989322795, + "tag": "0000_huge_luke_cage", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1785005330713, + "tag": "0001_ambitious_emma_frost", + "breakpoints": true + } + ] +}
\ No newline at end of file diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts new file mode 100644 index 0000000..ce95493 --- /dev/null +++ b/api/src/db/schema.ts @@ -0,0 +1,240 @@ +/** + * schema.ts + * + * Fonte única de verdade do banco de dados. + * Todos os tipos TypeScript são *inferidos* deste arquivo — nunca definidos + * manualmente — garantindo que DB e aplicação nunca fiquem dessincronizados. + */ + +import { sql } from 'drizzle-orm'; +import { + sqliteTable, + text, + integer, + index, + uniqueIndex, +} from 'drizzle-orm/sqlite-core'; + +// ───────────────────────────────────────────────────────────────────────────── +// HELPERS +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Colunas de auditoria reutilizáveis. + * SQLite armazena timestamps como texto ISO 8601. + */ +const timestamps = { + createdAt: text('created_at') + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`), + updatedAt: text('updated_at') + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`), +}; + +// ───────────────────────────────────────────────────────────────────────────── +// ENUMS (simulados via union types — SQLite não tem tipo ENUM nativo) +// ───────────────────────────────────────────────────────────────────────────── + +export const USER_ROLES = ['requester', 'agent', 'admin'] as const; +export type UserRole = (typeof USER_ROLES)[number]; + +export const TICKET_STATUSES = [ + 'open', // Chamado aberto, ainda não atribuído + 'in_progress', // Atribuído a um agente, em andamento + 'waiting', // Aguardando resposta do solicitante + 'resolved', // Solução aplicada, aguardando confirmação + 'closed', // Encerrado definitivamente +] as const; +export type TicketStatus = (typeof TICKET_STATUSES)[number]; + +export const TICKET_PRIORITIES = ['low', 'medium', 'high', 'critical'] as const; +export type TicketPriority = (typeof TICKET_PRIORITIES)[number]; + +// ───────────────────────────────────────────────────────────────────────────── +// TABELA: users +// ───────────────────────────────────────────────────────────────────────────── + +export const users = sqliteTable( + 'users', + { + id: integer('id').primaryKey({ autoIncrement: true }), + + // Identificação + name: text('name').notNull(), + email: text('email').notNull(), + password: text('password').notNull(), // hash bcrypt — nunca plain text + + // Controle de acesso + role: text('role', { enum: USER_ROLES }).notNull().default('requester'), + + // Conta ativa? Soft-delete sem perder histórico de tickets/comments + isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true), + + ...timestamps, + }, + (table) => ({ + // Garante emails únicos na camada do banco + emailUniqueIdx: uniqueIndex('users_email_unique_idx').on(table.email), + + // Consultas frequentes por role (ex: listar todos os agentes) + roleIdx: index('users_role_idx').on(table.role), + }), +); + +// ───────────────────────────────────────────────────────────────────────────── +// TABELA: categories +// ───────────────────────────────────────────────────────────────────────────── + +export const categories = sqliteTable( + 'categories', + { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + + // Slug para uso em URLs e filtros: "hardware", "acesso-sistema", etc. + slug: text('slug').notNull(), + + description: text('description'), + + ...timestamps, + }, + (table) => ({ + slugUniqueIdx: uniqueIndex('categories_slug_unique_idx').on(table.slug), + }), +); + +// ───────────────────────────────────────────────────────────────────────────── +// TABELA: tickets +// ───────────────────────────────────────────────────────────────────────────── + +export const tickets = sqliteTable( + 'tickets', + { + id: integer('id').primaryKey({ autoIncrement: true }), + + // Conteúdo + title: text('title').notNull(), + description: text('description').notNull(), + + // Classificação + status: text('status', { enum: TICKET_STATUSES }) + .notNull() + .default('open'), + + priority: text('priority', { enum: TICKET_PRIORITIES }) + .notNull() + .default('medium'), + + // ── Relacionamentos ──────────────────────────────────────────────────── + // Quem abriu o chamado (obrigatório) + creatorId: integer('creator_id') + .notNull() + .references(() => users.id, { onDelete: 'restrict' }), + + // Agente responsável (opcional — pode ser atribuído depois) + assigneeId: integer('assignee_id') + .references(() => users.id, { onDelete: 'set null' }), + + // Categoria do chamado (opcional — pode ser classificado depois) + categoryId: integer('category_id') + .references(() => categories.id, { onDelete: 'set null' }), + + // Data limite para resolução (SLA) + dueAt: text('due_at'), + + // Data em que o chamado foi efetivamente resolvido + resolvedAt: text('resolved_at'), + + ...timestamps, + }, + (table) => ({ + // Índices para os filtros mais comuns no painel do agente + statusIdx: index('tickets_status_idx').on(table.status), + priorityIdx: index('tickets_priority_idx').on(table.priority), + assigneeIdx: index('tickets_assignee_id_idx').on(table.assigneeId), + creatorIdx: index('tickets_creator_id_idx').on(table.creatorId), + categoryIdx: index('tickets_category_id_idx').on(table.categoryId), + + // Índice composto: painel do agente filtra por status + assignee com frequência + statusAssigneeIdx: index('tickets_status_assignee_idx').on( + table.status, + table.assigneeId, + ), + }), +); + +// ───────────────────────────────────────────────────────────────────────────── +// TABELA: comments +// ───────────────────────────────────────────────────────────────────────────── + +export const comments = sqliteTable( + 'comments', + { + id: integer('id').primaryKey({ autoIncrement: true }), + + content: text('content').notNull(), + + /** + * Diferencia mensagens públicas de notas internas da equipe. + * true → nota interna (visível apenas para agentes/admins) + * false → resposta pública (visível para o solicitante) + */ + isInternal: integer('is_internal', { mode: 'boolean' }) + .notNull() + .default(false), + + // ── Relacionamentos ──────────────────────────────────────────────────── + ticketId: integer('ticket_id') + .notNull() + .references(() => tickets.id, { onDelete: 'cascade' }), + + authorId: integer('author_id') + .notNull() + .references(() => users.id, { onDelete: 'restrict' }), + + ...timestamps, + }, + (table) => ({ + // Buscar todos os comentários de um ticket é a query mais frequente + ticketIdx: index('comments_ticket_id_idx').on(table.ticketId), + authorIdx: index('comments_author_id_idx').on(table.authorId), + }), +); + +// ───────────────────────────────────────────────────────────────────────────── +// TABELA: system_settings +// ───────────────────────────────────────────────────────────────────────────── + +export const systemSettings = sqliteTable( + 'system_settings', + { + id: integer('id').primaryKey({ autoIncrement: true }), + + // Configurações Globais + allowRequesterChat: integer('allow_requester_chat', { mode: 'boolean' }).notNull().default(true), + + ...timestamps, + } +); + +// ───────────────────────────────────────────────────────────────────────────── +// TIPOS INFERIDOS +// +// Use estes tipos em toda a aplicação. +// Ex: função que retorna um ticket → retorno tipado como `Ticket` +// ───────────────────────────────────────────────────────────────────────────── + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; + +export type Category = typeof categories.$inferSelect; +export type NewCategory = typeof categories.$inferInsert; + +export type Ticket = typeof tickets.$inferSelect; +export type NewTicket = typeof tickets.$inferInsert; + +export type Comment = typeof comments.$inferSelect; +export type NewComment = typeof comments.$inferInsert; + +export type SystemSetting = typeof systemSettings.$inferSelect; diff --git a/api/src/db/seed.ts b/api/src/db/seed.ts new file mode 100644 index 0000000..2cde3ce --- /dev/null +++ b/api/src/db/seed.ts @@ -0,0 +1,60 @@ +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import * as schema from './schema.js'; +import bcrypt from 'bcryptjs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DATABASE_URL = process.env['DATABASE_URL'] ?? 'file:./helpdesk.db'; +const dbPath = DATABASE_URL.replace(/^file:/, ''); + +const sqlite = new Database(dbPath); +const db = drizzle(sqlite, { schema }); + +async function seed() { + console.log('🌱 Verificando usuário admin...'); + + const existingAdmin = await db.query.users.findFirst({ + where: (users, { eq }) => eq(users.email, 'admin@helpdesk.local'), + }); + + if (!existingAdmin) { + const hashedPassword = await bcrypt.hash('admin123', 10); + + await db.insert(schema.users).values({ + name: 'Administrador TI', + email: 'admin@helpdesk.local', + password: hashedPassword, + role: 'admin', + isActive: true, + }); + + console.log('✅ Usuário admin criado com sucesso!'); + console.log('E-mail: admin@helpdesk.local'); + console.log('Senha: admin123'); + } else { + console.log('✅ Usuário admin já existe!'); + } + + console.log('🌱 Verificando categorias...'); + const existingCategories = await db.query.categories.findMany(); + if (existingCategories.length === 0) { + await db.insert(schema.categories).values([ + { name: 'Hardware', slug: 'hardware', description: 'Problemas físicos em computadores, impressoras e periféricos' }, + { name: 'Software', slug: 'software', description: 'Instalação, erros e licenças de programas' }, + { name: 'Redes e Internet', slug: 'redes', description: 'Sem conexão, lentidão ou falhas no Wi-Fi/Cabo' }, + { name: 'Acessos e Senhas', slug: 'acessos', description: 'Redefinição de senha, criação de e-mail e acessos a sistemas' }, + { name: 'Dúvidas e Suporte', slug: 'duvidas', description: 'Dúvidas gerais sobre uso de sistemas e equipamentos' } + ]); + console.log('✅ Categorias padrão criadas com sucesso!'); + } else { + console.log('✅ Categorias já existem!'); + } + + sqlite.close(); +} + +seed().catch((err) => { + console.error('❌ Erro no seed:', err); + process.exit(1); +}); diff --git a/api/src/modules/auth/auth.controller.ts b/api/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..8aec643 --- /dev/null +++ b/api/src/modules/auth/auth.controller.ts @@ -0,0 +1,114 @@ +/** + * auth.controller.ts + * Lógica de negócio de autenticação. + */ + +import bcrypt from 'bcryptjs'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { + findUserByEmail, + findUserById, + createUser, +} from './auth.repository.js'; +import type { RegisterBody, LoginBody } from './auth.schema.js'; + +const SALT_ROUNDS = 12; + +// ─── POST /auth/register ────────────────────────────────────────────────────── + +export async function registerHandler( + request: FastifyRequest<{ Body: RegisterBody }>, + reply: FastifyReply, +) { + const { name, email, password, role } = request.body; + const db = request.server.db; + + // Verifica duplicidade de e-mail + const existing = await findUserByEmail(db, email); + if (existing) { + return reply.conflict('Este e-mail já está cadastrado.'); + } + + // Apenas admins podem criar agentes ou outros admins + if ((role === 'agent' || role === 'admin')) { + const callerUser = request.user as { role?: string } | undefined; + if (!callerUser || callerUser.role !== 'admin') { + return reply.forbidden('Apenas administradores podem criar agentes ou admins.'); + } + } + + // Hash da senha (bcrypt com salt 12 ≈ ~250ms — seguro e aceitável) + const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); + + const user = await createUser(db, { + name, + email, + password: hashedPassword, + role, + }); + + // Assina o JWT com o payload mínimo necessário + const token = await reply.jwtSign({ + id: user.id, + email: user.email, + role: user.role, + }); + + return reply.code(201).send({ token, user }); +} + +// ─── POST /auth/login ───────────────────────────────────────────────────────── + +export async function loginHandler( + request: FastifyRequest<{ Body: LoginBody }>, + reply: FastifyReply, +) { + const { email, password } = request.body; + const db = request.server.db; + + const user = await findUserByEmail(db, email); + + // Mensagem genérica: não revela se o e-mail existe ou não (segurança) + const INVALID_MSG = 'E-mail ou senha incorretos.'; + + if (!user) { + // Executa bcrypt mesmo sem usuário para evitar timing attacks + await bcrypt.compare(password, '$2b$12$invalidhashplaceholderthatnevermatchesanypassword'); + return reply.unauthorized(INVALID_MSG); + } + + if (!user.isActive) { + return reply.forbidden('Conta desativada. Entre em contato com o administrador.'); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + if (!isPasswordValid) { + return reply.unauthorized(INVALID_MSG); + } + + const token = await reply.jwtSign({ + id: user.id, + email: user.email, + role: user.role, + }); + + // Retorna o user sem o campo password + const { password: _pwd, ...safeUser } = user; + return reply.send({ token, user: safeUser }); +} + +// ─── GET /auth/me ───────────────────────────────────────────────────────────── + +export async function meHandler( + request: FastifyRequest, + reply: FastifyReply, +) { + // request.user foi injetado pelo decorator authenticate + const user = await findUserById(request.server.db, request.user.id); + + if (!user) { + return reply.unauthorized('Usuário não encontrado.'); + } + + return reply.send(user); +} diff --git a/api/src/modules/auth/auth.repository.ts b/api/src/modules/auth/auth.repository.ts new file mode 100644 index 0000000..25a931b --- /dev/null +++ b/api/src/modules/auth/auth.repository.ts @@ -0,0 +1,68 @@ +/** + * auth.repository.ts + * Queries de banco exclusivas do módulo de autenticação. + */ + +import { eq } from 'drizzle-orm'; +import type { Database } from '@/db/connection.js'; +import { users, type NewUser } from '@/db/schema.js'; + +// ─── Busca com senha (apenas para verificação no login) ─────────────────────── + +export async function findUserByEmail(db: Database, email: string) { + const [user] = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + + return user ?? null; +} + +// ─── Busca segura (sem password) ────────────────────────────────────────────── + +export async function findUserById(db: Database, id: number) { + const [user] = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + role: users.role, + isActive: users.isActive, + createdAt: users.createdAt, + updatedAt: users.updatedAt, + }) + .from(users) + .where(eq(users.id, id)) + .limit(1); + + return user ?? null; +} + +// ─── Criação de usuário ─────────────────────────────────────────────────────── + +export async function createUser( + db: Database, + data: Pick<NewUser, 'name' | 'email' | 'password' | 'role'>, +) { + const now = new Date().toISOString(); + + const [user] = await db + .insert(users) + .values({ + ...data, + isActive: true, + createdAt: now, + updatedAt: now, + }) + .returning({ + id: users.id, + name: users.name, + email: users.email, + role: users.role, + isActive: users.isActive, + createdAt: users.createdAt, + }); + + return user!; +} diff --git a/api/src/modules/auth/auth.routes.ts b/api/src/modules/auth/auth.routes.ts new file mode 100644 index 0000000..1de0e16 --- /dev/null +++ b/api/src/modules/auth/auth.routes.ts @@ -0,0 +1,55 @@ +/** + * auth.routes.ts + * + * Rotas públicas e protegidas de autenticação: + * POST /api/v1/auth/register → cria conta + * POST /api/v1/auth/login → retorna JWT + * GET /api/v1/auth/me → dados do usuário logado (protegida) + */ + +import type { FastifyInstance } from 'fastify'; +import { + registerBodySchema, + loginBodySchema, +} from './auth.schema.js'; +import { + registerHandler, + loginHandler, + meHandler, +} from './auth.controller.js'; + +async function authRoutes(fastify: FastifyInstance): Promise<void> { + + // ── POST /auth/register ──────────────────────────────────────────────────── + fastify.post('/register', { + schema: { + tags: ['Auth'], + summary: 'Registra um novo usuário', + body: registerBodySchema, + }, + handler: registerHandler, + }); + + // ── POST /auth/login ─────────────────────────────────────────────────────── + fastify.post('/login', { + schema: { + tags: ['Auth'], + summary: 'Autentica e retorna um JWT', + body: loginBodySchema, + }, + handler: loginHandler, + }); + + // ── GET /auth/me ─────────────────────────────────────────────────────────── + fastify.get('/me', { + schema: { + tags: ['Auth'], + summary: 'Retorna o usuário autenticado', + security: [{ bearerAuth: [] }], + }, + preHandler: [fastify.authenticate], + handler: meHandler, + }); +} + +export default authRoutes; diff --git a/api/src/modules/auth/auth.schema.ts b/api/src/modules/auth/auth.schema.ts new file mode 100644 index 0000000..7c9e8cd --- /dev/null +++ b/api/src/modules/auth/auth.schema.ts @@ -0,0 +1,66 @@ +/** + * auth.schema.ts + * Schemas Zod para registro, login e resposta de usuário. + */ + +import { z } from 'zod'; +import { USER_ROLES } from '@/db/schema.js'; + +// ─── Registro ───────────────────────────────────────────────────────────────── + +export const registerBodySchema = z.object({ + name: z + .string({ required_error: 'Nome é obrigatório' }) + .min(2, 'Nome deve ter no mínimo 2 caracteres') + .max(100) + .trim(), + + email: z + .string({ required_error: 'E-mail é obrigatório' }) + .email('E-mail inválido') + .toLowerCase() + .trim(), + + password: z + .string({ required_error: 'Senha é obrigatória' }) + .min(8, 'Senha deve ter no mínimo 8 caracteres') + .max(100), + + // Somente admins podem criar agentes/admins — validado no controller + role: z.enum(USER_ROLES).default('requester'), +}); + +export type RegisterBody = z.infer<typeof registerBodySchema>; + +// ─── Login ──────────────────────────────────────────────────────────────────── + +export const loginBodySchema = z.object({ + email: z + .string({ required_error: 'E-mail é obrigatório' }) + .email('E-mail inválido') + .toLowerCase() + .trim(), + + password: z.string({ required_error: 'Senha é obrigatória' }), +}); + +export type LoginBody = z.infer<typeof loginBodySchema>; + +// ─── Shape de resposta (NUNCA inclui password) ──────────────────────────────── + +export const userResponseSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + role: z.enum(USER_ROLES), + isActive: z.boolean(), + createdAt: z.string(), +}); + +export const authResponseSchema = z.object({ + token: z.string(), + user: userResponseSchema, +}); + +export type UserResponse = z.infer<typeof userResponseSchema>; +export type AuthResponse = z.infer<typeof authResponseSchema>; 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>; 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>; diff --git a/api/src/modules/settings/settings.controller.ts b/api/src/modules/settings/settings.controller.ts new file mode 100644 index 0000000..479f476 --- /dev/null +++ b/api/src/modules/settings/settings.controller.ts @@ -0,0 +1,24 @@ +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { getSettings, updateSettings } from './settings.repository.js'; +import type { UpdateSettingsBody } from './settings.schema.js'; + +export async function getSettingsHandler( + request: FastifyRequest, + reply: FastifyReply, +) { + const settings = await getSettings(request.server.db); + return reply.send(settings); +} + +export async function updateSettingsHandler( + request: FastifyRequest<{ Body: UpdateSettingsBody }>, + reply: FastifyReply, +) { + // Apenas admins podem atualizar as configurações globais + if (request.user?.role !== 'admin') { + return reply.forbidden('Apenas administradores podem alterar configurações do sistema.'); + } + + const updated = await updateSettings(request.server.db, request.body); + return reply.send(updated); +} diff --git a/api/src/modules/settings/settings.repository.ts b/api/src/modules/settings/settings.repository.ts new file mode 100644 index 0000000..a3d38ba --- /dev/null +++ b/api/src/modules/settings/settings.repository.ts @@ -0,0 +1,26 @@ +import type { Database } from '@/db/connection.js'; +import { systemSettings } from '@/db/schema.js'; + +export async function getSettings(db: Database) { + let [settings] = await db.select().from(systemSettings).limit(1); + + if (!settings) { + [settings] = await db.insert(systemSettings).values({ allowRequesterChat: true }).returning(); + } + + return settings; +} + +export async function updateSettings(db: Database, data: { allowRequesterChat: boolean }) { + let [settings] = await db.select().from(systemSettings).limit(1); + + if (!settings) { + [settings] = await db.insert(systemSettings).values(data).returning(); + } else { + [settings] = await db.update(systemSettings) + .set({ allowRequesterChat: data.allowRequesterChat, updatedAt: new Date().toISOString() }) + .returning(); + } + + return settings; +} diff --git a/api/src/modules/settings/settings.routes.ts b/api/src/modules/settings/settings.routes.ts new file mode 100644 index 0000000..5ae97da --- /dev/null +++ b/api/src/modules/settings/settings.routes.ts @@ -0,0 +1,30 @@ +import type { FastifyInstance } from 'fastify'; +import { getSettingsHandler, updateSettingsHandler } from './settings.controller.js'; +import { SettingsSchema } from './settings.schema.js'; + +export default async function settingsRoutes(fastify: FastifyInstance) { + fastify.get( + '/', + { + schema: { + tags: ['Settings'], + summary: 'Obtém as configurações globais do sistema', + }, + preHandler: [fastify.authenticate], + }, + getSettingsHandler, + ); + + fastify.patch( + '/', + { + schema: { + tags: ['Settings'], + summary: 'Atualiza as configurações globais do sistema (apenas admin)', + body: SettingsSchema, + }, + preHandler: [fastify.authenticate], + }, + updateSettingsHandler, + ); +} diff --git a/api/src/modules/settings/settings.schema.ts b/api/src/modules/settings/settings.schema.ts new file mode 100644 index 0000000..cd3a19b --- /dev/null +++ b/api/src/modules/settings/settings.schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const SettingsSchema = z.object({ + allowRequesterChat: z.boolean(), +}); + +export type UpdateSettingsBody = z.infer<typeof SettingsSchema>; diff --git a/api/src/modules/stats/stats.controller.ts b/api/src/modules/stats/stats.controller.ts new file mode 100644 index 0000000..520f692 --- /dev/null +++ b/api/src/modules/stats/stats.controller.ts @@ -0,0 +1,15 @@ +/** + * stats.controller.ts + * Lógica do dashboard. + */ + +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { getTicketsStats } from './stats.repository.js'; + +export async function getStatsHandler( + request: FastifyRequest, + reply: FastifyReply, +) { + const stats = await getTicketsStats(request.server.db); + return reply.send(stats); +} diff --git a/api/src/modules/stats/stats.repository.ts b/api/src/modules/stats/stats.repository.ts new file mode 100644 index 0000000..d5a543d --- /dev/null +++ b/api/src/modules/stats/stats.repository.ts @@ -0,0 +1,41 @@ +/** + * stats.repository.ts + * Queries para obter os totais agrupados. + */ + +import { sql } from 'drizzle-orm'; +import type { Database } from '@/db/connection.js'; +import { tickets } from '@/db/schema.js'; + +export async function getTicketsStats(db: Database) { + // Uma única query para agrupar as contagens por status + const rows = await db + .select({ + status: tickets.status, + count: sql<number>`count(*)`, + }) + .from(tickets) + .groupBy(tickets.status); + + // Inicializa o mapa com 0 + const stats = { + totalOpen: 0, + totalInProgress: 0, + totalWaiting: 0, + totalResolved: 0, + totalClosed: 0, + }; + + for (const row of rows) { + if (row.status === 'open') stats.totalOpen = row.count; + if (row.status === 'in_progress') stats.totalInProgress = row.count; + if (row.status === 'waiting') stats.totalWaiting = row.count; + if (row.status === 'resolved') stats.totalResolved = row.count; + if (row.status === 'closed') stats.totalClosed = row.count; + } + + return { + ...stats, + totalPending: stats.totalOpen + stats.totalInProgress, + }; +} diff --git a/api/src/modules/stats/stats.routes.ts b/api/src/modules/stats/stats.routes.ts new file mode 100644 index 0000000..cf02c08 --- /dev/null +++ b/api/src/modules/stats/stats.routes.ts @@ -0,0 +1,22 @@ +/** + * stats.routes.ts + * + * GET /api/v1/stats → retorna agregados para o dashboard + */ + +import type { FastifyInstance } from 'fastify'; +import { statsResponseSchema } from './stats.schema.js'; +import { getStatsHandler } from './stats.controller.js'; + +async function statsRoutes(fastify: FastifyInstance): Promise<void> { + fastify.get('/', { + schema: { + tags: ['Dashboard'], + summary: 'Retorna métricas e agregados dos chamados', + }, + preHandler: [fastify.authenticate], + handler: getStatsHandler, + }); +} + +export default statsRoutes; diff --git a/api/src/modules/stats/stats.schema.ts b/api/src/modules/stats/stats.schema.ts new file mode 100644 index 0000000..1616937 --- /dev/null +++ b/api/src/modules/stats/stats.schema.ts @@ -0,0 +1,17 @@ +/** + * stats.schema.ts + * Schemas para o módulo de estatísticas (Dashboard). + */ + +import { z } from 'zod'; + +export const statsResponseSchema = z.object({ + totalOpen: z.number(), + totalInProgress: z.number(), + totalWaiting: z.number(), + totalResolved: z.number(), + totalClosed: z.number(), + totalPending: z.number(), // open + in_progress +}); + +export type StatsResponse = z.infer<typeof statsResponseSchema>; 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(), + }), +}); diff --git a/api/src/modules/users/users.controller.ts b/api/src/modules/users/users.controller.ts new file mode 100644 index 0000000..c3c06fd --- /dev/null +++ b/api/src/modules/users/users.controller.ts @@ -0,0 +1,129 @@ +import type { FastifyRequest, FastifyReply } from 'fastify'; +import bcrypt from 'bcryptjs'; +import { users } from '@/db/schema.js'; +import { eq, and, ne } from 'drizzle-orm'; +import type { CreateUserByAdminBody, UpdateUserBody } from './users.schema.js'; + +export async function listUsersHandler(request: FastifyRequest, reply: FastifyReply) { + const db = request.server.db; + const allUsers = await db.query.users.findMany({ + columns: { + password: false, // Don't return passwords + }, + orderBy: (users, { desc }) => [desc(users.createdAt)], + }); + + return reply.send(allUsers); +} + +export async function createUserHandler( + request: FastifyRequest<{ Body: CreateUserByAdminBody }>, + reply: FastifyReply, +) { + const db = request.server.db; + const { name, email, password, role } = request.body; + + // Verifica se email já existe + const existing = await db.query.users.findFirst({ + where: eq(users.email, email), + }); + + if (existing) { + return reply.conflict('Este e-mail já está cadastrado.'); + } + + const hashedPassword = await bcrypt.hash(password, 12); + + const [newUser] = await db.insert(users).values({ + name, + email, + password: hashedPassword, + role, + isActive: true, + }).returning({ + id: users.id, + name: users.name, + email: users.email, + role: users.role, + isActive: users.isActive, + createdAt: users.createdAt, + }); + + return reply.code(201).send(newUser); +} + +export async function updateUserHandler( + request: FastifyRequest<{ Params: { id: number }; Body: UpdateUserBody }>, + reply: FastifyReply, +) { + const db = request.server.db; + const { id } = request.params; + const { name, email, password, role, isActive } = request.body; + + const existingUser = await db.query.users.findFirst({ + where: eq(users.id, id), + }); + + if (!existingUser) { + return reply.notFound('Usuário não encontrado.'); + } + + if (email && email !== existingUser.email) { + const existingEmail = await db.query.users.findFirst({ + where: and(eq(users.email, email), ne(users.id, id)), + }); + if (existingEmail) { + return reply.conflict('Este e-mail já está em uso por outro usuário.'); + } + } + + const updates: Partial<typeof users.$inferInsert> = { + name, + email, + role, + isActive, + updatedAt: new Date().toISOString(), + }; + + if (password && password.trim() !== '') { + updates.password = await bcrypt.hash(password, 12); + } + + const [updatedUser] = await db.update(users) + .set(updates) + .where(eq(users.id, id)) + .returning({ + id: users.id, + name: users.name, + email: users.email, + role: users.role, + isActive: users.isActive, + createdAt: users.createdAt, + }); + + return reply.send(updatedUser); +} + +export async function deleteUserHandler( + request: FastifyRequest<{ Params: { id: number } }>, + reply: FastifyReply, +) { + const db = request.server.db; + const { id } = request.params; + + const existingUser = await db.query.users.findFirst({ + where: eq(users.id, id), + }); + + if (!existingUser) { + return reply.notFound('Usuário não encontrado.'); + } + + if (id === request.user?.id) { + return reply.badRequest('Você não pode excluir a própria conta.'); + } + + await db.delete(users).where(eq(users.id, id)); + + return reply.code(204).send(); +} diff --git a/api/src/modules/users/users.routes.ts b/api/src/modules/users/users.routes.ts new file mode 100644 index 0000000..c7f1846 --- /dev/null +++ b/api/src/modules/users/users.routes.ts @@ -0,0 +1,56 @@ +import type { FastifyInstance } from 'fastify'; +import { createUserByAdminBodySchema, updateUserBodySchema, userIdParamsSchema } from './users.schema.js'; +import { listUsersHandler, createUserHandler, updateUserHandler, deleteUserHandler } from './users.controller.js'; + +export default async function usersRoutes(fastify: FastifyInstance) { + // Middleware customizado para garantir que apenas admins possam acessar + const requireAdmin = async (request: any, reply: any) => { + if (request.user?.role !== 'admin') { + return reply.forbidden('Apenas administradores podem gerenciar usuários.'); + } + }; + + // ── GET /users ───────────────────────────────────────────────────────────── + fastify.get('/', { + schema: { + tags: ['Users'], + summary: 'Lista todos os usuários (admin)', + }, + preHandler: [fastify.authenticate, requireAdmin], + handler: listUsersHandler, + }); + + // ── POST /users ──────────────────────────────────────────────────────────── + fastify.post('/', { + schema: { + tags: ['Users'], + summary: 'Cria um novo usuário (admin)', + body: createUserByAdminBodySchema, + }, + preHandler: [fastify.authenticate, requireAdmin], + handler: createUserHandler, + }); + + // ── PATCH /users/:id ─────────────────────────────────────────────────────── + fastify.patch('/:id', { + schema: { + tags: ['Users'], + summary: 'Atualiza um usuário (admin)', + params: userIdParamsSchema, + body: updateUserBodySchema, + }, + preHandler: [fastify.authenticate, requireAdmin], + handler: updateUserHandler, + }); + + // ── DELETE /users/:id ────────────────────────────────────────────────────── + fastify.delete('/:id', { + schema: { + tags: ['Users'], + summary: 'Exclui um usuário (admin)', + params: userIdParamsSchema, + }, + preHandler: [fastify.authenticate, requireAdmin], + handler: deleteUserHandler, + }); +} diff --git a/api/src/modules/users/users.schema.ts b/api/src/modules/users/users.schema.ts new file mode 100644 index 0000000..ff3f459 --- /dev/null +++ b/api/src/modules/users/users.schema.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; +import { USER_ROLES } from '@/db/schema.js'; + +export const createUserByAdminBodySchema = z.object({ + name: z.string().min(2).max(100).trim(), + email: z.string().email().toLowerCase().trim(), + password: z.string().min(8).max(100), + role: z.enum(USER_ROLES), +}); + +export type CreateUserByAdminBody = z.infer<typeof createUserByAdminBodySchema>; + +export const updateUserBodySchema = z.object({ + name: z.string().min(2).max(100).trim().optional(), + email: z.string().email().toLowerCase().trim().optional(), + password: z.string().min(8).max(100).optional().or(z.literal('')), + role: z.enum(USER_ROLES).optional(), + isActive: z.boolean().optional(), +}); + +export type UpdateUserBody = z.infer<typeof updateUserBodySchema>; + +export const userIdParamsSchema = z.object({ + id: z.coerce.number().positive(), +}); diff --git a/api/src/plugins/auth.plugin.ts b/api/src/plugins/auth.plugin.ts new file mode 100644 index 0000000..0e6718a --- /dev/null +++ b/api/src/plugins/auth.plugin.ts @@ -0,0 +1,93 @@ +/** + * auth.plugin.ts + * + * Registra o @fastify/jwt e expõe dois decoradores: + * fastify.authenticate → rejeita requests sem token válido (401) + * fastify.optionalAuthenticate → injeta o usuário se houver token, mas não falha + * + * Uso nas rotas: + * { preHandler: [fastify.authenticate] } + */ + +import fp from 'fastify-plugin'; +import jwt from '@fastify/jwt'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +// ─── Payload do token ───────────────────────────────────────────────────────── + +export interface JwtPayload { + id: number; + email: string; + role: 'requester' | 'agent' | 'admin'; +} + +// ─── Augmentação de tipos Fastify ───────────────────────────────────────────── + +declare module 'fastify' { + interface FastifyInstance { + authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>; + optionalAuthenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>; + } + interface FastifyRequest { + user: JwtPayload; + } +} + +declare module '@fastify/jwt' { + interface FastifyJWT { + payload: JwtPayload; + user: JwtPayload; + } +} + +// ─── Plugin ─────────────────────────────────────────────────────────────────── + +async function authPlugin(fastify: FastifyInstance): Promise<void> { + const secret = process.env['JWT_SECRET']; + if (!secret) { + throw new Error('JWT_SECRET não definido. Verifique o arquivo .env'); + } + + await fastify.register(jwt, { + secret, + sign: { + expiresIn: process.env['JWT_EXPIRES_IN'] ?? '7d', + }, + }); + + // ── authenticate ──────────────────────────────────────────────────────────── + // Exige token válido — retorna 401 se ausente ou expirado + fastify.decorate( + 'authenticate', + async (request: FastifyRequest, reply: FastifyReply) => { + try { + // Suporte para SSE (EventSource não envia headers, envia via query string) + const query = request.query as { token?: string }; + if (query.token && !request.headers.authorization) { + request.headers.authorization = `Bearer ${query.token}`; + } + await request.jwtVerify(); + } catch { + await reply.unauthorized('Token inválido ou expirado. Faça login novamente.'); + } + }, + ); + + // ── optionalAuthenticate ──────────────────────────────────────────────────── + // Tenta verificar o token mas não falha se ausente + fastify.decorate( + 'optionalAuthenticate', + async (request: FastifyRequest, _reply: FastifyReply) => { + try { + await request.jwtVerify(); + } catch { + // Sem token → request.user fica undefined — trate no controller + } + }, + ); +} + +export default fp(authPlugin, { + name: 'auth-plugin', + fastify: '4.x', +}); diff --git a/api/src/plugins/db.plugin.ts b/api/src/plugins/db.plugin.ts new file mode 100644 index 0000000..47c82dd --- /dev/null +++ b/api/src/plugins/db.plugin.ts @@ -0,0 +1,31 @@ +/** + * db.plugin.ts + * + * Plugin Fastify que disponibiliza o banco de dados como decorador. + * + * Após registrar este plugin, qualquer rota pode acessar o banco via: + * fastify.db ou request.server.db + * + * O uso de fastify-plugin (fp) é essencial: sem ele, o Fastify encapsularia + * o decorador apenas no escopo do plugin, tornando-o invisível para outros plugins. + */ + +import fp from 'fastify-plugin'; +import type { FastifyInstance } from 'fastify'; +import { db, type Database } from '@/db/connection.js'; + +// Augmenta a tipagem do Fastify para incluir `db` com type-safety completo +declare module 'fastify' { + interface FastifyInstance { + db: Database; + } +} + +async function dbPlugin(fastify: FastifyInstance): Promise<void> { + fastify.decorate('db', db); +} + +export default fp(dbPlugin, { + name: 'db-plugin', + fastify: '4.x', +}); diff --git a/api/src/plugins/events.plugin.ts b/api/src/plugins/events.plugin.ts new file mode 100644 index 0000000..fdf0e74 --- /dev/null +++ b/api/src/plugins/events.plugin.ts @@ -0,0 +1,34 @@ +/** + * events.plugin.ts + * + * Expõe um EventEmitter global no fastify para pub/sub em memória. + * Ideal para disparar Server-Sent Events (SSE) aos clientes conectados. + */ + +import fp from 'fastify-plugin'; +import { EventEmitter } from 'node:events'; +import type { FastifyInstance } from 'fastify'; + +export interface AppEventMap { + 'ticket.created': { id: number; title: string; creatorId: number }; + 'ticket.updated': { id: number; status: string; assigneeId: number | null; creatorId: number; authorId: number }; + 'comment.added': { ticketId: number; authorId: number; isInternal: boolean; creatorId: number; assigneeId: number | null }; +} + +declare module 'fastify' { + interface FastifyInstance { + events: EventEmitter; + } +} + +async function eventsPlugin(fastify: FastifyInstance): Promise<void> { + const emitter = new EventEmitter(); + // Permite muitos clientes SSE conectados simultaneamente + emitter.setMaxListeners(100); + + fastify.decorate('events', emitter); +} + +export default fp(eventsPlugin, { + name: 'events-plugin', +}); diff --git a/api/src/plugins/events.routes.ts b/api/src/plugins/events.routes.ts new file mode 100644 index 0000000..fd14732 --- /dev/null +++ b/api/src/plugins/events.routes.ts @@ -0,0 +1,58 @@ +/** + * events.routes.ts + * + * Endpoint SSE (Server-Sent Events). + * Mantém a conexão aberta e repassa eventos do `fastify.events` para o cliente. + */ + +import fp from 'fastify-plugin'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +async function eventsRoutes(fastify: FastifyInstance): Promise<void> { + fastify.get('/', { + schema: { + tags: ['Dashboard'], + summary: 'Stream de eventos em tempo real (SSE)', + }, + preHandler: [fastify.authenticate], + handler: (request: FastifyRequest, reply: FastifyReply) => { + // 1. Configura os headers para SSE + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + reply.raw.write('\n'); // Garante envio do header imediato + + // Helper para formatar pacote SSE + const sendEvent = (event: string, data: any) => { + reply.raw.write(`event: ${event}\n`); + reply.raw.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + // 2. Registra os listeners + const onTicketCreated = (data: any) => sendEvent('ticket.created', data); + const onTicketUpdated = (data: any) => sendEvent('ticket.updated', data); + const onCommentAdded = (data: any) => sendEvent('comment.added', data); + + fastify.events.on('ticket.created', onTicketCreated); + fastify.events.on('ticket.updated', onTicketUpdated); + fastify.events.on('comment.added', onCommentAdded); + + // Ping a cada 30s para manter a conexão ativa (evitar timeout do LB/Nginx) + const pingInterval = setInterval(() => { + reply.raw.write(':\n\n'); // SSE comment block serve como heartbeat + }, 30000); + + // 3. Limpeza quando o cliente desconecta + request.raw.on('close', () => { + clearInterval(pingInterval); + fastify.events.off('ticket.created', onTicketCreated); + fastify.events.off('ticket.updated', onTicketUpdated); + fastify.events.off('comment.added', onCommentAdded); + }); + }, + }); +} + +export default eventsRoutes; diff --git a/api/src/script.js b/api/src/script.js new file mode 100644 index 0000000..320c0c4 --- /dev/null +++ b/api/src/script.js @@ -0,0 +1,5 @@ +const { default: bcrypt } = await import("bcryptjs"); + +const hashedPassword = await bcrypt.hash("12345678", 12); + +console.log(hashedPassword)
\ No newline at end of file diff --git a/api/src/server.ts b/api/src/server.ts new file mode 100644 index 0000000..9ab75eb --- /dev/null +++ b/api/src/server.ts @@ -0,0 +1,115 @@ +/** + * server.ts + * + * Entry point do backend Helpdesk. + * + * Responsabilidades (em ordem): + * 1. Executar migrações do banco no boot + * 2. Criar e configurar a instância Fastify + * 3. Registrar plugins globais + * 4. Registrar módulos de rotas + * 5. Iniciar o servidor HTTP + */ + +import 'dotenv/config'; // Carrega .env antes de qualquer outro import +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import sensible from '@fastify/sensible'; +import { validatorCompiler } from 'fastify-type-provider-zod'; + +import { runMigrations } from '@/db/migrate.js'; +import dbPlugin from '@/plugins/db.plugin.js'; +import authPlugin from '@/plugins/auth.plugin.js'; +import authRoutes from '@/modules/auth/auth.routes.js'; +import ticketsRoutes from '@/modules/tickets/tickets.routes.js'; +import interactionsRoutes from '@/modules/interactions/interactions.routes.js'; +import categoriesRoutes from '@/modules/categories/categories.routes.js'; +import settingsRoutes from '@/modules/settings/settings.routes.js'; +import statsRoutes from '@/modules/stats/stats.routes.js'; +import usersRoutes from '@/modules/users/users.routes.js'; +import eventsPlugin from '@/plugins/events.plugin.js'; +import eventsRoutes from '@/plugins/events.routes.js'; + +// ─── Configuração ───────────────────────────────────────────────────────────── +const PORT = Number(process.env['PORT'] ?? 3000); +const HOST = process.env['HOST'] ?? '0.0.0.0'; +const IS_DEV = process.env['NODE_ENV'] !== 'production'; + +// ─── Factory da aplicação ───────────────────────────────────────────────────── +// Exportar como função facilita testes de integração (cada teste cria uma instância) +export async function buildApp() { + const fastify = Fastify({ + logger: { + level: IS_DEV ? 'info' : 'warn', + // Formatação legível no terminal em desenvolvimento + transport: IS_DEV + ? { target: 'pino-pretty', options: { colorize: true } } + : undefined, + }, + }); + + // Configura o Fastify para usar validação com Zod + fastify.setValidatorCompiler(validatorCompiler); + + // ── Plugins Globais ─────────────────────────────────────────────────────── + // CORS: permite requisições do frontend (Vite roda na porta 5173) + await fastify.register(cors, { + origin: IS_DEV ? 'http://localhost:5173' : process.env['FRONTEND_URL'], + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], + }); + + // Sensible: adiciona helpers como reply.notFound(), reply.badRequest(), etc. + await fastify.register(sensible); + + // Banco de dados: disponibiliza fastify.db em todas as rotas + await fastify.register(dbPlugin); + + // JWT: disponibiliza fastify.authenticate em todas as rotas + await fastify.register(authPlugin); + + // ── Plugins Locais ──────────────────────────────────────────────────────── + await fastify.register(eventsPlugin); + + // ── Rotas ───────────────────────────────────────────────────────────────── + // Auth: rotas públicas (sem authenticate) + await fastify.register(authRoutes, { prefix: '/api/v1/auth' }); + await fastify.register(statsRoutes, { prefix: '/api/v1/stats' }); + await fastify.register(eventsRoutes, { prefix: '/api/v1/events' }); + await fastify.register(categoriesRoutes, { prefix: '/api/v1/categories' }); + await fastify.register(settingsRoutes, { prefix: '/api/v1/settings' }); + await fastify.register(usersRoutes, { prefix: '/api/v1/users' }); + await fastify.register(ticketsRoutes, { prefix: '/api/v1/tickets' }); + await fastify.register(interactionsRoutes, { prefix: '/api/v1' }); + + // Health check — útil para monitoramento e load balancer + fastify.get('/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() })); + + return fastify; +} + +// ─── Bootstrap ─────────────────────────────────────────────────────────────── +async function start(): Promise<void> { + // 1. Aplica migrações pendentes antes de abrir o servidor para tráfego + await runMigrations(); + + // 2. Cria a instância configurada + const app = await buildApp(); + + // 3. Graceful shutdown — garante que requests em andamento terminem antes de fechar + const gracefulShutdown = async (signal: string) => { + app.log.info(`Sinal ${signal} recebido. Encerrando servidor...`); + await app.close(); + process.exit(0); + }; + + process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); // Kubernetes / systemd + process.on('SIGINT', () => gracefulShutdown('SIGINT')); // Ctrl+C no terminal + + // 4. Inicia o servidor + await app.listen({ port: PORT, host: HOST }); +} + +start().catch((err) => { + console.error('❌ Falha ao iniciar o servidor:', err); + process.exit(1); +}); diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 0000000..e11b6c1 --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + // ── Compilação ──────────────────────────────────────────────────────────── + "target": "ES2022", // Node 20 suporta ES2022 nativamente + "module": "ESNext", // Saída em ESModules (alinhado com "type":"module") + "moduleResolution": "bundler", // Compatível com tsx (dev) e esbuild/tsc (prod) + "lib": ["ES2022"], + + // ── Saída ───────────────────────────────────────────────────────────────── + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, // Gera .d.ts para uso interno entre módulos + "sourceMap": true, // Facilita debug em desenvolvimento + + // ── Strict Mode (obrigatório para código limpo) ──────────────────────────── + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + + // ── Interoperabilidade ──────────────────────────────────────────────────── + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": true, // Força import type onde necessário + + // ── Path Aliases ────────────────────────────────────────────────────────── + // Permite imports como: import { db } from '@/db/connection' + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} |