aboutsummaryrefslogtreecommitdiff
path: root/api
diff options
context:
space:
mode:
authorzwlucas <lucas.fariamo08@gmail.com>2026-07-25 21:23:29 +0000
committerzwlucas <lucas.fariamo08@gmail.com>2026-07-25 21:23:29 +0000
commitef55d9bb24430610b60c1b77821307cd72c08c55 (patch)
treee6935a810b34370b0a4c821e55f6397aac50c832 /api
downloadhelpdesk-ef55d9bb24430610b60c1b77821307cd72c08c55.tar.gz
helpdesk-ef55d9bb24430610b60c1b77821307cd72c08c55.zip
feat: upload of the projectHEADmaster
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'api')
-rw-r--r--api/.env.example23
-rw-r--r--api/drizzle.config.ts32
-rw-r--r--api/package.json40
-rw-r--r--api/src/db/connection.ts37
-rw-r--r--api/src/db/migrate.ts56
-rw-r--r--api/src/db/migrations/0000_huge_luke_cage.sql61
-rw-r--r--api/src/db/migrations/0001_ambitious_emma_frost.sql6
-rw-r--r--api/src/db/migrations/meta/0000_snapshot.json446
-rw-r--r--api/src/db/migrations/meta/0001_snapshot.json486
-rw-r--r--api/src/db/migrations/meta/_journal.json20
-rw-r--r--api/src/db/schema.ts240
-rw-r--r--api/src/db/seed.ts60
-rw-r--r--api/src/modules/auth/auth.controller.ts114
-rw-r--r--api/src/modules/auth/auth.repository.ts68
-rw-r--r--api/src/modules/auth/auth.routes.ts55
-rw-r--r--api/src/modules/auth/auth.schema.ts66
-rw-r--r--api/src/modules/categories/categories.controller.ts127
-rw-r--r--api/src/modules/categories/categories.repository.ts80
-rw-r--r--api/src/modules/categories/categories.routes.ts92
-rw-r--r--api/src/modules/categories/categories.schema.ts76
-rw-r--r--api/src/modules/interactions/interactions.controller.ts190
-rw-r--r--api/src/modules/interactions/interactions.repository.ts135
-rw-r--r--api/src/modules/interactions/interactions.routes.ts86
-rw-r--r--api/src/modules/interactions/interactions.schema.ts101
-rw-r--r--api/src/modules/settings/settings.controller.ts24
-rw-r--r--api/src/modules/settings/settings.repository.ts26
-rw-r--r--api/src/modules/settings/settings.routes.ts30
-rw-r--r--api/src/modules/settings/settings.schema.ts7
-rw-r--r--api/src/modules/stats/stats.controller.ts15
-rw-r--r--api/src/modules/stats/stats.repository.ts41
-rw-r--r--api/src/modules/stats/stats.routes.ts22
-rw-r--r--api/src/modules/stats/stats.schema.ts17
-rw-r--r--api/src/modules/tickets/tickets.controller.ts112
-rw-r--r--api/src/modules/tickets/tickets.repository.ts145
-rw-r--r--api/src/modules/tickets/tickets.routes.ts75
-rw-r--r--api/src/modules/tickets/tickets.schema.ts89
-rw-r--r--api/src/modules/users/users.controller.ts129
-rw-r--r--api/src/modules/users/users.routes.ts56
-rw-r--r--api/src/modules/users/users.schema.ts25
-rw-r--r--api/src/plugins/auth.plugin.ts93
-rw-r--r--api/src/plugins/db.plugin.ts31
-rw-r--r--api/src/plugins/events.plugin.ts34
-rw-r--r--api/src/plugins/events.routes.ts58
-rw-r--r--api/src/script.js5
-rw-r--r--api/src/server.ts115
-rw-r--r--api/tsconfig.json37
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"]
+}