diff options
Diffstat (limited to 'api/src/db/schema.ts')
| -rw-r--r-- | api/src/db/schema.ts | 240 |
1 files changed, 240 insertions, 0 deletions
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; |