From b8a67e0a57d1bb96c167bb941dd5e4dd1470732f Mon Sep 17 00:00:00 2001 From: killer-cf Date: Mon, 1 Dec 2025 17:11:34 -0300 Subject: [PATCH] feat: Implement initial pedido (order) management, product catalog, and TI configuration features. --- .agent/rules/convex-svelte-best-practices.md | 127 + apps/web/src/lib/utils/masks.ts | 252 +- .../routes/(dashboard)/compras/+page.svelte | 46 +- .../(dashboard)/compras/produtos/+page.svelte | Bin 0 -> 7114 bytes .../routes/(dashboard)/pedidos/+page.svelte | 157 + .../(dashboard)/pedidos/[id]/+page.svelte | 609 +++ .../(dashboard)/pedidos/novo/+page.svelte | 358 ++ .../programas-esportivos/+page.svelte | 19 +- .../programas-esportivos/acoes/+page.svelte | 210 + .../src/routes/(dashboard)/ti/+page.svelte | 9 + .../(dashboard)/ti/configuracoes/+page.svelte | 94 + packages/backend/convex/_generated/api.d.ts | 8 + packages/backend/convex/acoes.ts | 56 + packages/backend/convex/config.ts | 38 + packages/backend/convex/pedidos.ts | 596 +++ packages/backend/convex/permissoesAcoes.ts | 94 + packages/backend/convex/produtos.ts | 69 + packages/backend/convex/schema.ts | 3621 +++++++++-------- 18 files changed, 4429 insertions(+), 1934 deletions(-) create mode 100644 .agent/rules/convex-svelte-best-practices.md create mode 100644 apps/web/src/routes/(dashboard)/compras/produtos/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/pedidos/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/programas-esportivos/acoes/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/ti/configuracoes/+page.svelte create mode 100644 packages/backend/convex/acoes.ts create mode 100644 packages/backend/convex/config.ts create mode 100644 packages/backend/convex/pedidos.ts create mode 100644 packages/backend/convex/produtos.ts diff --git a/.agent/rules/convex-svelte-best-practices.md b/.agent/rules/convex-svelte-best-practices.md new file mode 100644 index 0000000..9c834df --- /dev/null +++ b/.agent/rules/convex-svelte-best-practices.md @@ -0,0 +1,127 @@ +--- +trigger: glob +globs: **/*.svelte.ts,**/*.svelte +--- + +# Convex + Svelte Best Practices + +This document outlines the mandatory rules and best practices for integrating Convex with Svelte in this project. + +## 1. Imports + +Always use the following import paths. Do NOT use `$lib/convex` or relative paths for generated files unless specifically required by a local override. + +### Correct Imports: + +```typescript +import { useQuery, useConvexClient } from 'convex-svelte'; +import { api } from '@sgse-app/backend/convex/_generated/api'; +import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel'; +``` + +### Incorrect Imports (Avoid): + +```typescript +import { convex } from '$lib/convex'; // Avoid direct client usage for queries +import { api } from '$lib/convex/_generated/api'; // Incorrect path +import { api } from '../convex/_generated/api'; // Relative path +``` + +## 2. Data Fetching + +### Use `useQuery` for Reactivity + +Instead of manually fetching data inside `onMount`, use the `useQuery` hook. This ensures your data is reactive and automatically updates when the backend data changes. + +**Preferred Pattern:** + +```svelte + +``` + +**Avoid Pattern:** + +```svelte + +``` + +### Mutations + +Use `useConvexClient` to access the client for mutations. + +```svelte + +``` + +## 3. Type Safety + +### No `any` + +Strictly avoid using `any`. The Convex generated data model provides precise types for all your tables. + +### Use Generated Types + +Use `Doc<"tableName">` for full document objects and `Id<"tableName">` for IDs. + +**Correct:** + +```typescript +import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel'; + +let selectedTask: Doc<'tasks'> | null = $state(null); +let taskId: Id<'tasks'>; +``` + +**Incorrect:** + +```typescript +let selectedTask: any = $state(null); +let taskId: string; +``` + +### Union Types for Enums + +When dealing with status fields or other enums, define the specific union type instead of casting to `any`. + +**Correct:** + +```typescript +async function updateStatus(newStatus: 'pending' | 'completed' | 'archived') { + // ... +} +``` + +**Incorrect:** + +```typescript +async function updateStatus(newStatus: string) { + // ... + status: newStatus as any; // Avoid this +} +``` diff --git a/apps/web/src/lib/utils/masks.ts b/apps/web/src/lib/utils/masks.ts index d418b61..8b5efbd 100644 --- a/apps/web/src/lib/utils/masks.ts +++ b/apps/web/src/lib/utils/masks.ts @@ -2,185 +2,191 @@ /** Remove all non-digit characters from string */ export const onlyDigits = (value: string): string => { - return (value || "").replace(/\D/g, ""); + return (value || '').replace(/\D/g, ''); }; /** Format CPF: 000.000.000-00 */ export const maskCPF = (value: string): string => { - const digits = onlyDigits(value).slice(0, 11); - return digits - .replace(/(\d{3})(\d)/, "$1.$2") - .replace(/(\d{3})(\d)/, "$1.$2") - .replace(/(\d{3})(\d{1,2})$/, "$1-$2"); + const digits = onlyDigits(value).slice(0, 11); + return digits + .replace(/(\d{3})(\d)/, '$1.$2') + .replace(/(\d{3})(\d)/, '$1.$2') + .replace(/(\d{3})(\d{1,2})$/, '$1-$2'); }; /** Validate CPF format and checksum */ export const validateCPF = (value: string): boolean => { - const digits = onlyDigits(value); - - if (digits.length !== 11 || /^([0-9])\1+$/.test(digits)) { - return false; - } - - const calculateDigit = (base: string, factor: number): number => { - let sum = 0; - for (let i = 0; i < base.length; i++) { - sum += parseInt(base[i]) * (factor - i); - } - const rest = (sum * 10) % 11; - return rest === 10 ? 0 : rest; - }; - - const digit1 = calculateDigit(digits.slice(0, 9), 10); - const digit2 = calculateDigit(digits.slice(0, 10), 11); - - return digits[9] === String(digit1) && digits[10] === String(digit2); + const digits = onlyDigits(value); + + if (digits.length !== 11 || /^([0-9])\1+$/.test(digits)) { + return false; + } + + const calculateDigit = (base: string, factor: number): number => { + let sum = 0; + for (let i = 0; i < base.length; i++) { + sum += parseInt(base[i]) * (factor - i); + } + const rest = (sum * 10) % 11; + return rest === 10 ? 0 : rest; + }; + + const digit1 = calculateDigit(digits.slice(0, 9), 10); + const digit2 = calculateDigit(digits.slice(0, 10), 11); + + return digits[9] === String(digit1) && digits[10] === String(digit2); }; /** Format CEP: 00000-000 */ export const maskCEP = (value: string): string => { - const digits = onlyDigits(value).slice(0, 8); - return digits.replace(/(\d{5})(\d{1,3})$/, "$1-$2"); + const digits = onlyDigits(value).slice(0, 8); + return digits.replace(/(\d{5})(\d{1,3})$/, '$1-$2'); }; /** Format CNPJ: 00.000.000/0000-00 */ export const maskCNPJ = (value: string): string => { - const digits = onlyDigits(value).slice(0, 14); - return digits - .replace(/(\d{2})(\d)/, "$1.$2") - .replace(/(\d{3})(\d)/, "$1.$2") - .replace(/(\d{3})(\d)/, "$1/$2") - .replace(/(\d{4})(\d{1,2})$/, "$1-$2"); + const digits = onlyDigits(value).slice(0, 14); + return digits + .replace(/(\d{2})(\d)/, '$1.$2') + .replace(/(\d{3})(\d)/, '$1.$2') + .replace(/(\d{3})(\d)/, '$1/$2') + .replace(/(\d{4})(\d{1,2})$/, '$1-$2'); }; /** Format phone: (00) 0000-0000 or (00) 00000-0000 */ export const maskPhone = (value: string): string => { - const digits = onlyDigits(value).slice(0, 11); - - if (digits.length <= 10) { - return digits - .replace(/(\d{2})(\d)/, "($1) $2") - .replace(/(\d{4})(\d{1,4})$/, "$1-$2"); - } - - return digits - .replace(/(\d{2})(\d)/, "($1) $2") - .replace(/(\d{5})(\d{1,4})$/, "$1-$2"); + const digits = onlyDigits(value).slice(0, 11); + + if (digits.length <= 10) { + return digits.replace(/(\d{2})(\d)/, '($1) $2').replace(/(\d{4})(\d{1,4})$/, '$1-$2'); + } + + return digits.replace(/(\d{2})(\d)/, '($1) $2').replace(/(\d{5})(\d{1,4})$/, '$1-$2'); }; /** Format date: dd/mm/aaaa */ export const maskDate = (value: string): string => { - const digits = onlyDigits(value).slice(0, 8); - return digits - .replace(/(\d{2})(\d)/, "$1/$2") - .replace(/(\d{2})(\d{1,4})$/, "$1/$2"); + const digits = onlyDigits(value).slice(0, 8); + return digits.replace(/(\d{2})(\d)/, '$1/$2').replace(/(\d{2})(\d{1,4})$/, '$1/$2'); }; /** Validate date in format dd/mm/aaaa */ export const validateDate = (value: string): boolean => { - const match = value.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); - if (!match) return false; - - const day = Number(match[1]); - const month = Number(match[2]) - 1; - const year = Number(match[3]); - - const date = new Date(year, month, day); - - return ( - date.getFullYear() === year && - date.getMonth() === month && - date.getDate() === day - ); + const match = value.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + if (!match) return false; + + const day = Number(match[1]); + const month = Number(match[2]) - 1; + const year = Number(match[3]); + + const date = new Date(year, month, day); + + return date.getFullYear() === year && date.getMonth() === month && date.getDate() === day; }; /** Format UF: uppercase, max 2 chars */ export const maskUF = (value: string): string => { - return (value || "").toUpperCase().replace(/[^A-Z]/g, "").slice(0, 2); + return (value || '') + .toUpperCase() + .replace(/[^A-Z]/g, '') + .slice(0, 2); }; /** Format RG by UF */ const rgFormatByUF: Record = { - RJ: [2, 3, 2, 1], - SP: [2, 3, 3, 1], - MG: [2, 3, 3, 1], - ES: [2, 3, 3, 1], - PR: [2, 3, 3, 1], - SC: [2, 3, 3, 1], - RS: [2, 3, 3, 1], - BA: [2, 3, 3, 1], - PE: [2, 3, 3, 1], - CE: [2, 3, 3, 1], - PA: [2, 3, 3, 1], - AM: [2, 3, 3, 1], - AC: [2, 3, 3, 1], - AP: [2, 3, 3, 1], - AL: [2, 3, 3, 1], - RN: [2, 3, 3, 1], - PB: [2, 3, 3, 1], - MA: [2, 3, 3, 1], - PI: [2, 3, 3, 1], - DF: [2, 3, 3, 1], - GO: [2, 3, 3, 1], - MT: [2, 3, 3, 1], - MS: [2, 3, 3, 1], - RO: [2, 3, 3, 1], - RR: [2, 3, 3, 1], - TO: [2, 3, 3, 1], + RJ: [2, 3, 2, 1], + SP: [2, 3, 3, 1], + MG: [2, 3, 3, 1], + ES: [2, 3, 3, 1], + PR: [2, 3, 3, 1], + SC: [2, 3, 3, 1], + RS: [2, 3, 3, 1], + BA: [2, 3, 3, 1], + PE: [2, 3, 3, 1], + CE: [2, 3, 3, 1], + PA: [2, 3, 3, 1], + AM: [2, 3, 3, 1], + AC: [2, 3, 3, 1], + AP: [2, 3, 3, 1], + AL: [2, 3, 3, 1], + RN: [2, 3, 3, 1], + PB: [2, 3, 3, 1], + MA: [2, 3, 3, 1], + PI: [2, 3, 3, 1], + DF: [2, 3, 3, 1], + GO: [2, 3, 3, 1], + MT: [2, 3, 3, 1], + MS: [2, 3, 3, 1], + RO: [2, 3, 3, 1], + RR: [2, 3, 3, 1], + TO: [2, 3, 3, 1] }; export const maskRGByUF = (uf: string, value: string): string => { - const raw = (value || "").toUpperCase().replace(/[^0-9X]/g, ""); - const [a, b, c, dv] = rgFormatByUF[uf?.toUpperCase()] ?? [2, 3, 3, 1]; - const baseMax = a + b + c; - const baseDigits = raw.replace(/X/g, "").slice(0, baseMax); - const verifier = raw.slice(baseDigits.length, baseDigits.length + dv).slice(0, 1); - - const g1 = baseDigits.slice(0, a); - const g2 = baseDigits.slice(a, a + b); - const g3 = baseDigits.slice(a + b, a + b + c); - - let formatted = g1; - if (g2) formatted += `.${g2}`; - if (g3) formatted += `.${g3}`; - if (verifier) formatted += `-${verifier}`; - - return formatted; + const raw = (value || '').toUpperCase().replace(/[^0-9X]/g, ''); + const [a, b, c, dv] = rgFormatByUF[uf?.toUpperCase()] ?? [2, 3, 3, 1]; + const baseMax = a + b + c; + const baseDigits = raw.replace(/X/g, '').slice(0, baseMax); + const verifier = raw.slice(baseDigits.length, baseDigits.length + dv).slice(0, 1); + + const g1 = baseDigits.slice(0, a); + const g2 = baseDigits.slice(a, a + b); + const g3 = baseDigits.slice(a + b, a + b + c); + + let formatted = g1; + if (g2) formatted += `.${g2}`; + if (g3) formatted += `.${g3}`; + if (verifier) formatted += `-${verifier}`; + + return formatted; }; export const padRGLeftByUF = (uf: string, value: string): string => { - const raw = (value || "").toUpperCase().replace(/[^0-9X]/g, ""); - const [a, b, c, dv] = rgFormatByUF[uf?.toUpperCase()] ?? [2, 3, 3, 1]; - const baseMax = a + b + c; - let base = raw.replace(/X/g, ""); - const verifier = raw.slice(base.length, base.length + dv).slice(0, 1); - - if (base.length < baseMax) { - base = base.padStart(baseMax, "0"); - } - - return maskRGByUF(uf, base + (verifier || "")); + const raw = (value || '').toUpperCase().replace(/[^0-9X]/g, ''); + const [a, b, c, dv] = rgFormatByUF[uf?.toUpperCase()] ?? [2, 3, 3, 1]; + const baseMax = a + b + c; + let base = raw.replace(/X/g, ''); + const verifier = raw.slice(base.length, base.length + dv).slice(0, 1); + + if (base.length < baseMax) { + base = base.padStart(baseMax, '0'); + } + + return maskRGByUF(uf, base + (verifier || '')); }; /** Format account number */ export const maskContaBancaria = (value: string): string => { - const digits = onlyDigits(value); - return digits; + const digits = onlyDigits(value); + return digits; }; /** Format zone and section for voter title */ export const maskZonaSecao = (value: string): string => { - const digits = onlyDigits(value).slice(0, 4); - return digits; + const digits = onlyDigits(value).slice(0, 4); + return digits; }; /** Format general numeric field */ export const maskNumeric = (value: string): string => { - return onlyDigits(value); + return onlyDigits(value); +}; + +/** Format Brazilian currency (e.g. R$ 1.234,56) */ +export const maskCurrencyBRL = (value: string): string => { + const digits = onlyDigits(value); + if (!digits) return ''; + + const int = parseInt(digits, 10); + const amount = int / 100; + + return `R$ ${amount.toLocaleString('pt-BR', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })}`; }; /** Remove extra spaces and trim */ export const normalizeText = (value: string): string => { - return (value || "").replace(/\s+/g, " ").trim(); + return (value || '').replace(/\s+/g, ' ').trim(); }; - diff --git a/apps/web/src/routes/(dashboard)/compras/+page.svelte b/apps/web/src/routes/(dashboard)/compras/+page.svelte index 53bee00..b24031f 100644 --- a/apps/web/src/routes/(dashboard)/compras/+page.svelte +++ b/apps/web/src/routes/(dashboard)/compras/+page.svelte @@ -1,5 +1,5 @@ @@ -25,22 +25,40 @@ -
-
-
- diff --git a/apps/web/src/routes/(dashboard)/compras/produtos/+page.svelte b/apps/web/src/routes/(dashboard)/compras/produtos/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..9c273621fdb0b283b5b3ae6db0b465ceb9e3f3c3 GIT binary patch literal 7114 zcmdT}>uwvz74F~FQ%qR{lu#{6%d+FDB%M_D4+Dwe+C~weD1zbcklc9ol9^eGG|lJf z6XXf$JS9QtynO#ivN=PI6f9~w%O%bWS)C(A(3btca%UcZCK`vG`ViJ;}fD8 z4%s`wGgGVt`+>|Gwl%8GTT^Rmb7ptj$79%L5IzoMq0K4V?!e0*7d~Ojqg*KP&5y&H zWiTL{u+N{_-~X}SC2O7+)#C0=h5p)+?Dq(zYIXMx`}xJ76k79zpaZK`%Ahoy!X?uN z?mkMSFibD&E4K*2?fUVYOD*U;`Y!UqK*HDg1$HTt)iERi9Jyz2spqvSUy`2j>-H!~ zs=5>xtlvMy^NPz_z0#&Ad0vM_Q#ABkRJtweKI5A8wNR@f!^>yZ7dW{!t>X60QynGN z`c?;O>Ty5#s6RbnBx^Befy6B&(E?D)QfFrH zU6I2+Nz&1mHV0J$87WRm-%$HnJcknp2i76q_4_A|fiqEHOmD<@M^fyVfWxfx2f~tL zeoji~kQ=gv^p=m&0st8|1D)zLOCUBFKfvhJy8V-i;&YuVgS7gy^u_Y_uLa>mg@nO4zdlFRLP~ z4LQ12CdGFw<0e}|N3R=~LYd?KD-;aIYo@teafLi%e*_H)PG8hXs1=qqno3b|DZR;} zF{9UT^qpRMDUgohm<)7i6DJVa|M#fQ^Fo!!{STsK83YucIF(?Q3oX9qOsq567K$P1 zh08Znzv!QwFeOj|DpE8$^Z~w8qO4csJ67b(`QSb~c?s?_6xm@sbgg@ObTrP36`Bs$ zdeVcAh8O6ntX!uY?SwVyGwSMHqH>l#V{<9itT3X~X@)??j_+F($N46m33DYxMZ1#m z@^N5o#M-21Ysuy~Bc0VU?@izOo;4mWA6xQSYfN2HjMNqSVD@pcJw9QRX{QXH_z<%4 z-jrg&oAivSx~+1Nr)AD&i*zPikvb{;h9Z%SSSvoksxFi>{|++hkf8~zdsG1@m)DWOKP`Z zmk%-c;2HVgiSvOJbK{=Y+J#1m64jQ>D4;!b$Mg!KC`aPY8L_r$gvz)U>7_b?VANRu&G+w$gu)~7^KNz~lb7PC;fY#u@`U#gHC62_u@c})5j=|wj?eTy*?j}`W?!PVAR2$;U3y{ z2t7|*ZzD_lc8z0l8_S4~opJ1cCuGyAMvA?Z{{1>raCIu7TE*jS%^=EMtQD@hyHgHr zMZopV@T_Q56Yr$S{XE)btJsgNjcS`G~01uS5>pMJ$_P8BV`SKpN zpur2O2U1iEv$V-O3L$W#dexGGbJ9ESh03|FWL5(Y&Fph%o}Dy!BdR4@tv?hfJm+b#oS4SMXKDE2G0a2=-ES|WIH56kBH)BinbD>q!WDb%!{?i8A^aK z>4<$wpN<^FTslPJfyu3a(<>PP(8eKGi|BwMhUXnb*8xmQNL-mPM)AB$z6SmQVhkm? zi=3MDIVKJkJ*{PcLEW5*`Le&Q7ugFLZm8uG=u<{UM+lPz|Qz@K%4 z_rdtv07uVbdUOCN%sF13g;=mS-Man|b#M1~K$O>4n3HhTXQ-WoZ2P-_Z2`*92+R^w zy2LR|r|e~Rvh7}NcR{p{X>sy-Xwk`7@|lnwq0ZP$*4al_DngB-Ru)3-#oj|IyR$q8u)vck{8eEJaqTq^Gw!355dz#H^MHmyw z^Tb^bLa#M@KxF*^v3wXnI6B840I*Q)v1pNdv4dzdki>ixH=vk&Gt|fnOifzcEy5Mq zA&js|i63yC6d?`WUW0zl60Ws{llIe?8{I87aG<|9ccFWlt(Q*@hAwgP(<$^o z=KBdu=!4^=9m8k!)P^$ literal 0 HcmV?d00001 diff --git a/apps/web/src/routes/(dashboard)/pedidos/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/+page.svelte new file mode 100644 index 0000000..03419cb --- /dev/null +++ b/apps/web/src/routes/(dashboard)/pedidos/+page.svelte @@ -0,0 +1,157 @@ + + +
+
+

Pedidos

+ + + Novo Pedido + +
+ + {#if loading} +

Carregando...

+ {:else if error} +

{error}

+ {:else} +
+ + + + + + + + + + + + {#each pedidos as pedido (pedido._id)} + + + + + + + + {/each} + {#if pedidos.length === 0} + + + + {/if} + +
Número SEIStatusAçãoData de CriaçãoAções
+ {#if pedido.numeroSei} + {pedido.numeroSei} + {:else} + Sem número SEI + {/if} + + + {formatStatus(pedido.status)} + + + {getAcaoNome(pedido.acaoId)} + + {formatDate(pedido.criadoEm)} + + + + Visualizar + +
Nenhum pedido cadastrado.
+
+ {/if} +
diff --git a/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte new file mode 100644 index 0000000..30caf9a --- /dev/null +++ b/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte @@ -0,0 +1,609 @@ + + +
+ {#if loading} +

Carregando...

+ {:else if error} +

{error}

+ {:else if pedido} +
+
+

+ {#if editingSei} +
+ + + +
+ {:else} +
+ Pedido {pedido.numeroSei || 'sem número SEI'} + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} + + {/if} +
+ {/if} + + {formatStatus(pedido.status)} + +

+ {#if acao} +

Ação: {acao.nome} ({acao.tipo})

+ {/if} + {#if !pedido.numeroSei} +

+ ⚠️ Este pedido não possui número SEI. Adicione um número SEI quando disponível. +

+ {/if} +
+ +
+ {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} + + {/if} + + {#if pedido.status === 'aguardando_aceite'} + + + {/if} + + {#if pedido.status === 'em_analise'} + + + {/if} + + {#if pedido.status !== 'cancelado' && pedido.status !== 'concluido'} + + {/if} +
+
+ + +
+
+

Itens do Pedido

+ {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} + + {/if} +
+ + {#if showAddItem} +
+
+
+ + +
+
+ + +
+
+ + (newItem.valorEstimado = maskCurrencyBRL(e.currentTarget.value))} + class="w-full rounded-md border-gray-300 text-sm shadow-sm" + placeholder="R$ 0,00" + /> +
+
+ + +
+
+
+ {/if} + + + + + + + + + + + + + + {#each items as item (item._id)} + + + + + + + + + {/each} + {#if items.length === 0} + + + + {:else} + + + + + {/if} + +
ProdutoQuantidadeValor EstimadoAdicionado PorTotalAções
{getProductName(item.produtoId)} + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} + + handleUpdateQuantity(item._id, parseInt(e.currentTarget.value) || 1)} + class="w-20 rounded border px-2 py-1 text-sm" + /> + {:else} + {item.quantidade} + {/if} + + {maskCurrencyBRL(item.valorEstimado) || 'R$ 0,00'} + + {item.adicionadoPorNome} + + R$ {calculateItemTotal(item.valorEstimado, item.quantidade) + .toFixed(2) + .replace('.', ',')} + + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} + + {/if} +
Nenhum item adicionado.
+ Total Geral: + + R$ {totalGeral.toFixed(2).replace('.', ',')} +
+
+ + +
+

Histórico

+
+ {#if history.length === 0} +

Nenhum histórico disponível.

+ {:else} + {#each history as entry (entry._id)} +
+
+ {getHistoryIcon(entry.acao)} +
+
+

+ {formatHistoryEntry(entry)} +

+

+ {new Date(entry.data).toLocaleString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} +

+
+
+ {/each} + {/if} +
+
+ {/if} +
diff --git a/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte new file mode 100644 index 0000000..6039953 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte @@ -0,0 +1,358 @@ + + +
+

Novo Pedido

+ +
+ {#if error} +
+ {error} +
+ {/if} + +
+
+ + +

+ Você pode adicionar o número SEI posteriormente, se necessário. +

+
+ +
+ + {#if loading} +

Carregando ações...

+ {:else} + + {/if} +
+ +
+ + +
+ +
+ + {#if searchQuery.length > 0} +
+ {#if searchResults === undefined} +

Carregando...

+ {:else if searchResults.length === 0} +

Nenhum produto encontrado.

+ {:else} +
    + {#each searchResults as produto (produto._id)} +
  • + +
  • + {/each} +
+ {/if} +
+ {/if} + + {#if selectedProdutos.length > 0} +
+

Produtos Selecionados:

+
    + {#each selectedProdutos as item (item.produto._id)} +
  • + {item.produto.nome} + +
    + + +
    +
  • + {/each} +
+
+ {/if} +
+ + {#if warning} +
+ {warning} +
+ {/if} + + {#if checking} +

Verificando pedidos existentes...

+ {/if} + + {#if existingPedidos.length > 0} +
+

+ Os pedidos abaixo estão em rascunho/análise. Você pode abri-los para adicionar itens. +

+
    + {#each existingPedidos as pedido (pedido._id)} +
  • +
    +
    +
    + Pedido {pedido.numeroSei || 'sem número SEI'} — {formatStatus(pedido.status)} +
    +
    + Ação: {getAcaoNome(pedido.acaoId)} +
    +
    + + Abrir pedido + +
    + + {#if getMatchingInfo(pedido)} +
    + {getMatchingInfo(pedido)} +
    + {/if} +
  • + {/each} +
+
+ {/if} + +
+ + Cancelar + + +
+
+
+
diff --git a/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte b/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte index d294ee3..4744222 100644 --- a/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte @@ -1,5 +1,5 @@ @@ -43,6 +43,23 @@
+ +
+
+
+ +
+

Ações

+
+

+ Gerencie ações, projetos e leis relacionadas aos programas esportivos. +

+
+
+
diff --git a/apps/web/src/routes/(dashboard)/programas-esportivos/acoes/+page.svelte b/apps/web/src/routes/(dashboard)/programas-esportivos/acoes/+page.svelte new file mode 100644 index 0000000..0bd5a59 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/programas-esportivos/acoes/+page.svelte @@ -0,0 +1,210 @@ + + +
+
+

Ações

+ +
+ + {#if loading} +

Carregando...

+ {:else if error} +

{error}

+ {:else} +
+ + + + + + + + + + {#each acoes as acao (acao._id)} + + + + + + {/each} + {#if acoes.length === 0} + + + + {/if} + +
NomeTipoAções
{acao.nome} + + {acao.tipo === 'projeto' ? 'Projeto' : 'Lei'} + + + + +
Nenhuma ação cadastrada.
+
+ {/if} + + {#if showModal} +
+
+ +

{editingId ? 'Editar' : 'Novo'} Ação

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ {/if} +
diff --git a/apps/web/src/routes/(dashboard)/ti/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte index faa37e5..84f40d8 100644 --- a/apps/web/src/routes/(dashboard)/ti/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/+page.svelte @@ -367,6 +367,15 @@ palette: 'accent', icon: 'building' }, + { + title: 'Configurações Gerais', + description: + 'Configure opções gerais do sistema, incluindo setor de compras e outras configurações administrativas.', + ctaLabel: 'Configurar', + href: '/(dashboard)/ti/configuracoes', + palette: 'secondary', + icon: 'control' + }, { title: 'Documentação', description: diff --git a/apps/web/src/routes/(dashboard)/ti/configuracoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/configuracoes/+page.svelte new file mode 100644 index 0000000..9c82ee7 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes/+page.svelte @@ -0,0 +1,94 @@ + + +
+

Configurações Gerais

+ + {#if loading} +

Carregando...

+ {:else} +
+

Setor de Compras

+

+ Selecione o setor responsável por receber e aprovar pedidos de compra. +

+ + {#if error} +
+ {error} +
+ {/if} + + {#if success} +
+ {success} +
+ {/if} + +
+ + +
+ + +
+ {/if} +
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 770c2b4..d5115b6 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -8,6 +8,7 @@ * @module */ +import type * as acoes from "../acoes.js"; import type * as actions_email from "../actions/email.js"; import type * as actions_linkPreview from "../actions/linkPreview.js"; import type * as actions_pushNotifications from "../actions/pushNotifications.js"; @@ -21,6 +22,7 @@ import type * as auth_utils from "../auth/utils.js"; import type * as chamadas from "../chamadas.js"; import type * as chamados from "../chamados.js"; import type * as chat from "../chat.js"; +import type * as config from "../config.js"; import type * as configuracaoEmail from "../configuracaoEmail.js"; import type * as configuracaoJitsi from "../configuracaoJitsi.js"; import type * as configuracaoPonto from "../configuracaoPonto.js"; @@ -43,9 +45,11 @@ import type * as logsAcesso from "../logsAcesso.js"; import type * as logsAtividades from "../logsAtividades.js"; import type * as logsLogin from "../logsLogin.js"; import type * as monitoramento from "../monitoramento.js"; +import type * as pedidos from "../pedidos.js"; import type * as permissoesAcoes from "../permissoesAcoes.js"; import type * as pontos from "../pontos.js"; import type * as preferenciasNotificacao from "../preferenciasNotificacao.js"; +import type * as produtos from "../produtos.js"; import type * as pushNotifications from "../pushNotifications.js"; import type * as roles from "../roles.js"; import type * as saldoFerias from "../saldoFerias.js"; @@ -67,6 +71,7 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + acoes: typeof acoes; "actions/email": typeof actions_email; "actions/linkPreview": typeof actions_linkPreview; "actions/pushNotifications": typeof actions_pushNotifications; @@ -80,6 +85,7 @@ declare const fullApi: ApiFromModules<{ chamadas: typeof chamadas; chamados: typeof chamados; chat: typeof chat; + config: typeof config; configuracaoEmail: typeof configuracaoEmail; configuracaoJitsi: typeof configuracaoJitsi; configuracaoPonto: typeof configuracaoPonto; @@ -102,9 +108,11 @@ declare const fullApi: ApiFromModules<{ logsAtividades: typeof logsAtividades; logsLogin: typeof logsLogin; monitoramento: typeof monitoramento; + pedidos: typeof pedidos; permissoesAcoes: typeof permissoesAcoes; pontos: typeof pontos; preferenciasNotificacao: typeof preferenciasNotificacao; + produtos: typeof produtos; pushNotifications: typeof pushNotifications; roles: typeof roles; saldoFerias: typeof saldoFerias; diff --git a/packages/backend/convex/acoes.ts b/packages/backend/convex/acoes.ts new file mode 100644 index 0000000..0097ef1 --- /dev/null +++ b/packages/backend/convex/acoes.ts @@ -0,0 +1,56 @@ +import { mutation, query } from './_generated/server'; +import { v } from 'convex/values'; +import { getCurrentUserFunction } from './auth'; + +export const list = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query('acoes').collect(); + } +}); + +export const create = mutation({ + args: { + nome: v.string(), + tipo: v.union(v.literal('projeto'), v.literal('lei')) + }, + handler: async (ctx, args) => { + const user = await getCurrentUserFunction(ctx); + if (!user) throw new Error('Unauthorized'); + + return await ctx.db.insert('acoes', { + ...args, + criadoPor: user._id, + criadoEm: Date.now() + }); + } +}); + +export const update = mutation({ + args: { + id: v.id('acoes'), + nome: v.string(), + tipo: v.union(v.literal('projeto'), v.literal('lei')) + }, + handler: async (ctx, args) => { + const user = await getCurrentUserFunction(ctx); + if (!user) throw new Error('Unauthorized'); + + await ctx.db.patch(args.id, { + nome: args.nome, + tipo: args.tipo + }); + } +}); + +export const remove = mutation({ + args: { + id: v.id('acoes') + }, + handler: async (ctx, args) => { + const user = await getCurrentUserFunction(ctx); + if (!user) throw new Error('Unauthorized'); + + await ctx.db.delete(args.id); + } +}); diff --git a/packages/backend/convex/config.ts b/packages/backend/convex/config.ts new file mode 100644 index 0000000..d12a2a6 --- /dev/null +++ b/packages/backend/convex/config.ts @@ -0,0 +1,38 @@ +import { mutation, query } from './_generated/server'; +import { v } from 'convex/values'; +import { getCurrentUserFunction } from './auth'; + +export const getComprasSetor = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query('config').first(); + } +}); + +export const updateComprasSetor = mutation({ + args: { + setorId: v.id('setores') + }, + handler: async (ctx, args) => { + const user = await getCurrentUserFunction(ctx); + if (!user) throw new Error('Unauthorized'); + + // Check if user has permission (e.g., admin or TI) - For now, assuming any auth user can set it, + // but in production should be restricted. + + const existingConfig = await ctx.db.query('config').first(); + + if (existingConfig) { + await ctx.db.patch(existingConfig._id, { + comprasSetorId: args.setorId, + atualizadoEm: Date.now() + }); + } else { + await ctx.db.insert('config', { + comprasSetorId: args.setorId, + criadoPor: user._id, + atualizadoEm: Date.now() + }); + } + } +}); diff --git a/packages/backend/convex/pedidos.ts b/packages/backend/convex/pedidos.ts new file mode 100644 index 0000000..96691c2 --- /dev/null +++ b/packages/backend/convex/pedidos.ts @@ -0,0 +1,596 @@ +import { mutation, query, internalMutation } from './_generated/server'; +import { v } from 'convex/values'; +import { getCurrentUserFunction } from './auth'; +import { api, internal } from './_generated/api'; +import type { Doc, Id } from './_generated/dataModel'; +import type { QueryCtx, MutationCtx } from './_generated/server'; + +// ========== HELPERS ========== + +async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) { + const user = await getCurrentUserFunction(ctx); + if (!user) throw new Error('Unauthorized'); + return user; +} + +// ========== QUERIES ========== + +export const list = query({ + args: {}, + returns: v.array( + v.object({ + _id: v.id('pedidos'), + _creationTime: v.number(), + numeroSei: v.optional(v.string()), + status: v.union( + v.literal('em_rascunho'), + v.literal('aguardando_aceite'), + v.literal('em_analise'), + v.literal('precisa_ajustes'), + v.literal('cancelado'), + v.literal('concluido') + ), + acaoId: v.optional(v.id('acoes')), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + ), + handler: async (ctx) => { + return await ctx.db.query('pedidos').collect(); + } +}); + +export const get = query({ + args: { id: v.id('pedidos') }, + returns: v.union( + v.object({ + _id: v.id('pedidos'), + _creationTime: v.number(), + numeroSei: v.optional(v.string()), + status: v.union( + v.literal('em_rascunho'), + v.literal('aguardando_aceite'), + v.literal('em_analise'), + v.literal('precisa_ajustes'), + v.literal('cancelado'), + v.literal('concluido') + ), + acaoId: v.optional(v.id('acoes')), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoEm: v.number() + }), + v.null() + ), + handler: async (ctx, args) => { + return await ctx.db.get(args.id); + } +}); + +export const getItems = query({ + args: { pedidoId: v.id('pedidos') }, + returns: v.array( + v.object({ + _id: v.id('pedidoItems'), + _creationTime: v.number(), + pedidoId: v.id('pedidos'), + produtoId: v.id('produtos'), + valorEstimado: v.string(), + valorReal: v.optional(v.string()), + quantidade: v.number(), + adicionadoPor: v.id('funcionarios'), + adicionadoPorNome: v.string(), + criadoEm: v.number() + }) + ), + handler: async (ctx, args) => { + const items = await ctx.db + .query('pedidoItems') + .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) + .collect(); + + // Get employee names + const itemsWithNames = await Promise.all( + items.map(async (item) => { + const funcionario = await ctx.db.get(item.adicionadoPor); + return { + ...item, + adicionadoPorNome: funcionario?.nome || 'Desconhecido' + }; + }) + ); + + return itemsWithNames; + } +}); + +export const getHistory = query({ + args: { pedidoId: v.id('pedidos') }, + handler: async (ctx, args) => { + const history = await ctx.db + .query('historicoPedidos') + .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) + .order('desc') + .collect(); + + // Get user names + const historyWithNames = await Promise.all( + history.map(async (entry) => { + const usuario = await ctx.db.get(entry.usuarioId); + return { + _id: entry._id, + _creationTime: entry._creationTime, + pedidoId: entry.pedidoId, + usuarioId: entry.usuarioId, + usuarioNome: usuario?.nome || 'Desconhecido', + acao: entry.acao, + detalhes: entry.detalhes, + data: entry.data + }; + }) + ); + + return historyWithNames; + } +}); + +export const checkExisting = query({ + args: { + acaoId: v.optional(v.id('acoes')), + numeroSei: v.optional(v.string()), + produtoIds: v.optional(v.array(v.id('produtos'))) + }, + returns: v.array( + v.object({ + _id: v.id('pedidos'), + _creationTime: v.number(), + numeroSei: v.optional(v.string()), + status: v.union( + v.literal('em_rascunho'), + v.literal('aguardando_aceite'), + v.literal('em_analise'), + v.literal('precisa_ajustes'), + v.literal('cancelado'), + v.literal('concluido') + ), + acaoId: v.optional(v.id('acoes')), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoEm: v.number(), + matchingItems: v.optional( + v.array( + v.object({ + produtoId: v.id('produtos'), + quantidade: v.number() + }) + ) + ) + }) + ), + handler: async (ctx, args) => { + const user = await getCurrentUserFunction(ctx); + if (!user) return []; + + const openStatuses: Array< + 'em_rascunho' | 'aguardando_aceite' | 'em_analise' | 'precisa_ajustes' + > = ['em_rascunho', 'aguardando_aceite', 'em_analise', 'precisa_ajustes']; + + // 1) Buscar todos os pedidos "abertos" usando o índice by_status + let pedidosAbertos: Doc<'pedidos'>[] = []; + for (const status of openStatuses) { + const partial = await ctx.db + .query('pedidos') + .withIndex('by_status', (q) => q.eq('status', status)) + .collect(); + pedidosAbertos = pedidosAbertos.concat(partial); + } + + // 2) Filtros opcionais: acaoId e numeroSei + pedidosAbertos = pedidosAbertos.filter((p) => { + if (args.acaoId && p.acaoId !== args.acaoId) return false; + if (args.numeroSei && p.numeroSei !== args.numeroSei) return false; + return true; + }); + + // 3) Filtro por produtos (se informado) e coleta de matchingItems + const resultados = []; + + for (const pedido of pedidosAbertos) { + let include = true; + let matchingItems: { produtoId: Id<'produtos'>; quantidade: number }[] = []; + + // Se houver filtro de produtos, verificamos se o pedido tem ALGUM dos produtos + if (args.produtoIds && args.produtoIds.length > 0) { + const items = await ctx.db + .query('pedidoItems') + .withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedido._id)) + .collect(); + + // const pedidoProdutoIds = new Set(items.map((i) => i.produtoId)); // Unused + const matching = items.filter((i) => args.produtoIds?.includes(i.produtoId)); + + if (matching.length > 0) { + matchingItems = matching.map((i) => ({ + produtoId: i.produtoId, + quantidade: i.quantidade + })); + } else { + // Se foi pedido filtro por produtos e não tem nenhum match, ignoramos este pedido + // A MENOS que tenha dado match por numeroSei ou acaoId? + // A regra original era: "Filtro por produtos (se informado)" + // Se o usuário informou produtos, ele quer ver pedidos que tenham esses produtos. + // Mas se ele TAMBÉM informou numeroSei, talvez ele queira ver aquele pedido específico mesmo sem o produto? + // Vamos manter a lógica de "E": se informou produtos, tem que ter o produto. + include = false; + } + } + + if (include) { + resultados.push({ + _id: pedido._id, + _creationTime: pedido._creationTime, + numeroSei: pedido.numeroSei, + status: pedido.status, + acaoId: pedido.acaoId, + criadoPor: pedido.criadoPor, + criadoEm: pedido.criadoEm, + atualizadoEm: pedido.atualizadoEm, + matchingItems: matchingItems.length > 0 ? matchingItems : undefined + }); + } + } + + return resultados; + } +}); + +// ========== MUTATIONS ========== + +export const create = mutation({ + args: { + numeroSei: v.optional(v.string()), + acaoId: v.optional(v.id('acoes')) + }, + returns: v.id('pedidos'), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + + // 1. Check Config + const config = await ctx.db.query('config').first(); + if (!config || !config.comprasSetorId) { + throw new Error('Setor de Compras não configurado. Contate o administrador.'); + } + + // 2. Check Existing (Double check) + if (args.acaoId) { + const existing = await ctx.db + .query('pedidos') + .withIndex('by_acaoId', (q) => q.eq('acaoId', args.acaoId)) + .filter((q) => + q.or( + q.eq(q.field('status'), 'em_rascunho'), + q.eq(q.field('status'), 'aguardando_aceite'), + q.eq(q.field('status'), 'em_analise'), + q.eq(q.field('status'), 'precisa_ajustes') + ) + ) + .first(); + + if (existing) { + throw new Error('Já existe um pedido em andamento para esta ação.'); + } + } + + // 3. Create Order + const pedidoId = await ctx.db.insert('pedidos', { + numeroSei: args.numeroSei, + status: 'em_rascunho', + acaoId: args.acaoId, + criadoPor: user._id, + criadoEm: Date.now(), + atualizadoEm: Date.now() + }); + + // 4. Create History + await ctx.db.insert('historicoPedidos', { + pedidoId, + usuarioId: user._id, + acao: 'criacao', + detalhes: JSON.stringify({ numeroSei: args.numeroSei, acaoId: args.acaoId }), + data: Date.now() + }); + + return pedidoId; + } +}); + +export const updateSeiNumber = mutation({ + args: { + pedidoId: v.id('pedidos'), + numeroSei: v.string() + }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + const pedido = await ctx.db.get(args.pedidoId); + if (!pedido) throw new Error('Pedido not found'); + + // Check if SEI number is already taken by another order + const existing = await ctx.db + .query('pedidos') + .filter((q) => + q.and(q.eq(q.field('numeroSei'), args.numeroSei), q.neq(q.field('_id'), args.pedidoId)) + ) + .first(); + + if (existing) { + throw new Error('Este número SEI já está em uso por outro pedido.'); + } + + const oldSei = pedido.numeroSei; + + await ctx.db.patch(args.pedidoId, { + numeroSei: args.numeroSei, + atualizadoEm: Date.now() + }); + + await ctx.db.insert('historicoPedidos', { + pedidoId: args.pedidoId, + usuarioId: user._id, + acao: 'atualizacao_sei', + detalhes: JSON.stringify({ de: oldSei, para: args.numeroSei }), + data: Date.now() + }); + } +}); + +export const addItem = mutation({ + args: { + pedidoId: v.id('pedidos'), + produtoId: v.id('produtos'), + valorEstimado: v.string(), + quantidade: v.number() + }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + + // Ensure user has a funcionarioId linked + if (!user.funcionarioId) { + throw new Error('Usuário não vinculado a um funcionário.'); + } + + await ctx.db.insert('pedidoItems', { + pedidoId: args.pedidoId, + produtoId: args.produtoId, + valorEstimado: args.valorEstimado, + quantidade: args.quantidade, + adicionadoPor: user.funcionarioId, + criadoEm: Date.now() + }); + + await ctx.db.patch(args.pedidoId, { atualizadoEm: Date.now() }); + + await ctx.db.insert('historicoPedidos', { + pedidoId: args.pedidoId, + usuarioId: user._id, + acao: 'adicao_item', + detalhes: JSON.stringify({ + produtoId: args.produtoId, + valor: args.valorEstimado, + quantidade: args.quantidade + }), + data: Date.now() + }); + } +}); + +export const updateItemQuantity = mutation({ + args: { + itemId: v.id('pedidoItems'), + novaQuantidade: v.number() + }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + + if (!user.funcionarioId) { + throw new Error('Usuário não vinculado a um funcionário.'); + } + + const item = await ctx.db.get(args.itemId); + if (!item) throw new Error('Item não encontrado.'); + + const quantidadeAnterior = item.quantidade; + + // Check permission: only item owner can decrease quantity + const isOwner = item.adicionadoPor === user.funcionarioId; + const isDecreasing = args.novaQuantidade < quantidadeAnterior; + + if (isDecreasing && !isOwner) { + throw new Error( + 'Apenas quem adicionou este item pode diminuir a quantidade. Você pode apenas aumentar.' + ); + } + + // Update quantity + await ctx.db.patch(args.itemId, { quantidade: args.novaQuantidade }); + await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() }); + + // Create history entry + await ctx.db.insert('historicoPedidos', { + pedidoId: item.pedidoId, + usuarioId: user._id, + acao: 'alteracao_quantidade', + detalhes: JSON.stringify({ + produtoId: item.produtoId, + quantidadeAnterior, + novaQuantidade: args.novaQuantidade + }), + data: Date.now() + }); + } +}); + +export const removeItem = mutation({ + args: { + itemId: v.id('pedidoItems') + }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + + const item = await ctx.db.get(args.itemId); + if (!item) throw new Error('Item not found'); + + await ctx.db.delete(args.itemId); + await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() }); + + await ctx.db.insert('historicoPedidos', { + pedidoId: item.pedidoId, + usuarioId: user._id, + acao: 'remocao_item', + detalhes: JSON.stringify({ produtoId: item.produtoId, valor: item.valorEstimado }), + data: Date.now() + }); + } +}); + +export const updateStatus = mutation({ + args: { + pedidoId: v.id('pedidos'), + novoStatus: v.union( + v.literal('em_rascunho'), + v.literal('aguardando_aceite'), + v.literal('em_analise'), + v.literal('precisa_ajustes'), + v.literal('cancelado'), + v.literal('concluido') + ) + }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + const pedido = await ctx.db.get(args.pedidoId); + if (!pedido) throw new Error('Pedido not found'); + + const oldStatus = pedido.status; + + await ctx.db.patch(args.pedidoId, { + status: args.novoStatus, + atualizadoEm: Date.now() + }); + + await ctx.db.insert('historicoPedidos', { + pedidoId: args.pedidoId, + usuarioId: user._id, + acao: 'alteracao_status', + detalhes: JSON.stringify({ de: oldStatus, para: args.novoStatus }), + data: Date.now() + }); + + // Trigger Notifications + await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, { + pedidoId: args.pedidoId, + oldStatus, + newStatus: args.novoStatus, + actorId: user._id + }); + } +}); + +// ========== INTERNAL (NOTIFICATIONS) ========== + +export const notifyStatusChange = internalMutation({ + args: { + pedidoId: v.id('pedidos'), + oldStatus: v.string(), + newStatus: v.string(), + actorId: v.id('usuarios') + }, + returns: v.null(), + handler: async (ctx, args) => { + const pedido = await ctx.db.get(args.pedidoId); + if (!pedido) return; + + const actor = await ctx.db.get(args.actorId); + const actorName = actor ? actor.nome : 'Alguém'; + + const recipients = new Set(); // Set of User IDs + + // 1. If status is "aguardando_aceite", notify Purchasing Sector + if (args.newStatus === 'aguardando_aceite') { + const config = await ctx.db.query('config').first(); + if (config && config.comprasSetorId) { + // Find all employees in this sector + const funcionarioSetores = await ctx.db + .query('funcionarioSetores') + .withIndex('by_setorId', (q) => q.eq('setorId', config.comprasSetorId!)) + .collect(); + + const funcionarioIds = funcionarioSetores.map((fs) => fs.funcionarioId); + + // Find users linked to these employees + for (const fId of funcionarioIds) { + const user = await ctx.db + .query('usuarios') + .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', fId)) + .first(); + if (user) recipients.add(user._id); + } + } + } + + // 2. Notify "Involved" users (Creator + Item Adders) + // Always notify creator (unless they are the actor) + if (pedido.criadoPor !== args.actorId) { + recipients.add(pedido.criadoPor); + } + + // Notify item adders + const items = await ctx.db + .query('pedidoItems') + .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) + .collect(); + + for (const item of items) { + const user = await ctx.db + .query('usuarios') + .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', item.adicionadoPor)) + .first(); + if (user && user._id !== args.actorId) { + recipients.add(user._id); + } + } + + // Send Notifications + for (const recipientId of recipients) { + const recipientIdTyped = recipientId as Id<'usuarios'>; + + // 1. In-App Notification + await ctx.db.insert('notificacoes', { + usuarioId: recipientIdTyped, + tipo: 'alerta_seguranca', // Using alerta_seguranca as the closest match for system notifications + titulo: `Pedido ${pedido.numeroSei || 'sem número SEI'} atualizado`, + descricao: `Status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.`, + lida: false, + criadaEm: Date.now(), + remetenteId: args.actorId + }); + + // 2. Email Notification (Async) + const recipientUser = await ctx.db.get(recipientIdTyped); + if (recipientUser && recipientUser.email) { + // Using enfileirarEmail directly + await ctx.scheduler.runAfter(0, api.email.enfileirarEmail, { + destinatario: recipientUser.email, + destinatarioId: recipientIdTyped, + assunto: `Atualização no Pedido ${pedido.numeroSei || 'sem número SEI'}`, + corpo: `O pedido ${pedido.numeroSei || 'sem número SEI'} teve seu status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.`, + enviadoPor: args.actorId + }); + } + } + } +}); diff --git a/packages/backend/convex/permissoesAcoes.ts b/packages/backend/convex/permissoesAcoes.ts index 02cac86..aa93593 100644 --- a/packages/backend/convex/permissoesAcoes.ts +++ b/packages/backend/convex/permissoesAcoes.ts @@ -395,6 +395,100 @@ const PERMISSOES_BASE = { recurso: 'fluxos_documentos', acao: 'excluir', descricao: 'Excluir documentos de fluxos' + }, + // Pedidos + { + nome: 'pedidos.listar', + recurso: 'pedidos', + acao: 'listar', + descricao: 'Listar pedidos' + }, + { + nome: 'pedidos.criar', + recurso: 'pedidos', + acao: 'criar', + descricao: 'Criar novos pedidos' + }, + { + nome: 'pedidos.ver', + recurso: 'pedidos', + acao: 'ver', + descricao: 'Visualizar detalhes de pedidos' + }, + { + nome: 'pedidos.editar_status', + recurso: 'pedidos', + acao: 'editar_status', + descricao: 'Alterar status de pedidos' + }, + { + nome: 'pedidos.adicionar_item', + recurso: 'pedidos', + acao: 'adicionar_item', + descricao: 'Adicionar itens ao pedido' + }, + { + nome: 'pedidos.remover_item', + recurso: 'pedidos', + acao: 'remover_item', + descricao: 'Remover itens do pedido' + }, + // Produtos + { + nome: 'produtos.listar', + recurso: 'produtos', + acao: 'listar', + descricao: 'Listar produtos' + }, + { + nome: 'produtos.criar', + recurso: 'produtos', + acao: 'criar', + descricao: 'Criar novos produtos' + }, + { + nome: 'produtos.editar', + recurso: 'produtos', + acao: 'editar', + descricao: 'Editar produtos' + }, + { + nome: 'produtos.excluir', + recurso: 'produtos', + acao: 'excluir', + descricao: 'Excluir produtos' + }, + // Ações + { + nome: 'acoes.listar', + recurso: 'acoes', + acao: 'listar', + descricao: 'Listar ações' + }, + { + nome: 'acoes.criar', + recurso: 'acoes', + acao: 'criar', + descricao: 'Criar novas ações' + }, + { + nome: 'acoes.editar', + recurso: 'acoes', + acao: 'editar', + descricao: 'Editar ações' + }, + { + nome: 'acoes.excluir', + recurso: 'acoes', + acao: 'excluir', + descricao: 'Excluir ações' + }, + // Configuração Compras + { + nome: 'config.compras.gerenciar', + recurso: 'config', + acao: 'gerenciar_compras', + descricao: 'Gerenciar configurações de compras' } ] } as const; diff --git a/packages/backend/convex/produtos.ts b/packages/backend/convex/produtos.ts new file mode 100644 index 0000000..3161e36 --- /dev/null +++ b/packages/backend/convex/produtos.ts @@ -0,0 +1,69 @@ +import { mutation, query } from './_generated/server'; +import { v } from 'convex/values'; +import { getCurrentUserFunction } from './auth'; + +export const list = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query('produtos').collect(); + } +}); + +export const search = query({ + args: { query: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query('produtos') + .withSearchIndex('search_nome', (q) => q.search('nome', args.query)) + .take(10); + } +}); + +export const create = mutation({ + args: { + nome: v.string(), + valorEstimado: v.string(), + tipo: v.union(v.literal('servico'), v.literal('estrutura'), v.literal('insumo')) + }, + handler: async (ctx, args) => { + const user = await getCurrentUserFunction(ctx); + if (!user) throw new Error('Unauthorized'); + + return await ctx.db.insert('produtos', { + ...args, + criadoPor: user._id, + criadoEm: Date.now() + }); + } +}); + +export const update = mutation({ + args: { + id: v.id('produtos'), + nome: v.string(), + valorEstimado: v.string(), + tipo: v.union(v.literal('servico'), v.literal('estrutura'), v.literal('insumo')) + }, + handler: async (ctx, args) => { + const user = await getCurrentUserFunction(ctx); + if (!user) throw new Error('Unauthorized'); + + await ctx.db.patch(args.id, { + nome: args.nome, + valorEstimado: args.valorEstimado, + tipo: args.tipo + }); + } +}); + +export const remove = mutation({ + args: { + id: v.id('produtos') + }, + handler: async (ctx, args) => { + const user = await getCurrentUserFunction(ctx); + if (!user) throw new Error('Unauthorized'); + + await ctx.db.delete(args.id); + } +}); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 905319f..01f8edb 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -1,1856 +1,1885 @@ -import { defineSchema, defineTable } from "convex/server"; -import { Infer, v } from "convex/values"; +import { defineSchema, defineTable } from 'convex/server'; +import { Infer, v } from 'convex/values'; export const simboloTipo = v.union( - v.literal("cargo_comissionado"), - v.literal("funcao_gratificada") + v.literal('cargo_comissionado'), + v.literal('funcao_gratificada') ); export type SimboloTipo = Infer; export const ataqueCiberneticoTipo = v.union( - v.literal("phishing"), - v.literal("malware"), - v.literal("ransomware"), - v.literal("brute_force"), - v.literal("credential_stuffing"), - v.literal("sql_injection"), - v.literal("xss"), - v.literal("path_traversal"), - v.literal("command_injection"), - v.literal("nosql_injection"), - v.literal("xxe"), - v.literal("man_in_the_middle"), - v.literal("ddos"), - v.literal("engenharia_social"), - v.literal("cve_exploit"), - v.literal("apt"), - v.literal("zero_day"), - v.literal("supply_chain"), - v.literal("fileless_malware"), - v.literal("polymorphic_malware"), - v.literal("ransomware_lateral"), - v.literal("deepfake_phishing"), - v.literal("adversarial_ai"), - v.literal("side_channel"), - v.literal("firmware_bootloader"), - v.literal("bec"), - v.literal("botnet"), - v.literal("ot_ics"), - v.literal("quantum_attack") + v.literal('phishing'), + v.literal('malware'), + v.literal('ransomware'), + v.literal('brute_force'), + v.literal('credential_stuffing'), + v.literal('sql_injection'), + v.literal('xss'), + v.literal('path_traversal'), + v.literal('command_injection'), + v.literal('nosql_injection'), + v.literal('xxe'), + v.literal('man_in_the_middle'), + v.literal('ddos'), + v.literal('engenharia_social'), + v.literal('cve_exploit'), + v.literal('apt'), + v.literal('zero_day'), + v.literal('supply_chain'), + v.literal('fileless_malware'), + v.literal('polymorphic_malware'), + v.literal('ransomware_lateral'), + v.literal('deepfake_phishing'), + v.literal('adversarial_ai'), + v.literal('side_channel'), + v.literal('firmware_bootloader'), + v.literal('bec'), + v.literal('botnet'), + v.literal('ot_ics'), + v.literal('quantum_attack') ); export type AtaqueCiberneticoTipo = Infer; export const severidadeSeguranca = v.union( - v.literal("informativo"), - v.literal("baixo"), - v.literal("moderado"), - v.literal("alto"), - v.literal("critico") + v.literal('informativo'), + v.literal('baixo'), + v.literal('moderado'), + v.literal('alto'), + v.literal('critico') ); export type SeveridadeSeguranca = Infer; export const statusEventoSeguranca = v.union( - v.literal("detectado"), - v.literal("investigando"), - v.literal("contido"), - v.literal("falso_positivo"), - v.literal("escalado"), - v.literal("resolvido") + v.literal('detectado'), + v.literal('investigando'), + v.literal('contido'), + v.literal('falso_positivo'), + v.literal('escalado'), + v.literal('resolvido') ); export type StatusEventoSeguranca = Infer; export const sensorSegurancaTipo = v.union( - v.literal("network"), - v.literal("endpoint"), - v.literal("application"), - v.literal("gateway"), - v.literal("ot"), - v.literal("honeypot") + v.literal('network'), + v.literal('endpoint'), + v.literal('application'), + v.literal('gateway'), + v.literal('ot'), + v.literal('honeypot') ); export type SensorSegurancaTipo = Infer; export const sensorSegurancaStatus = v.union( - v.literal("ativo"), - v.literal("inativo"), - v.literal("degradado"), - v.literal("manutencao") + v.literal('ativo'), + v.literal('inativo'), + v.literal('degradado'), + v.literal('manutencao') ); export type SensorSegurancaStatus = Infer; export const threatIntelTipo = v.union( - v.literal("open_source"), - v.literal("commercial"), - v.literal("internal"), - v.literal("gov"), - v.literal("research") + v.literal('open_source'), + v.literal('commercial'), + v.literal('internal'), + v.literal('gov'), + v.literal('research') ); export const threatIntelFormato = v.union( - v.literal("json"), - v.literal("stix"), - v.literal("csv"), - v.literal("text"), - v.literal("custom") + v.literal('json'), + v.literal('stix'), + v.literal('csv'), + v.literal('text'), + v.literal('custom') ); export const acaoIncidenteTipo = v.union( - v.literal("block_ip"), - v.literal("unblock_ip"), - v.literal("block_port"), - v.literal("liberar_porta"), - v.literal("notificar"), - v.literal("isolar_host"), - v.literal("gerar_relatorio"), - v.literal("criar_ticket"), - v.literal("ajuste_regra"), - v.literal("custom") + v.literal('block_ip'), + v.literal('unblock_ip'), + v.literal('block_port'), + v.literal('liberar_porta'), + v.literal('notificar'), + v.literal('isolar_host'), + v.literal('gerar_relatorio'), + v.literal('criar_ticket'), + v.literal('ajuste_regra'), + v.literal('custom') ); export const acaoIncidenteStatus = v.union( - v.literal("pendente"), - v.literal("executando"), - v.literal("concluido"), - v.literal("falhou") + v.literal('pendente'), + v.literal('executando'), + v.literal('concluido'), + v.literal('falhou') ); export const reportStatus = v.union( - v.literal("pendente"), - v.literal("processando"), - v.literal("concluido"), - v.literal("falhou") + v.literal('pendente'), + v.literal('processando'), + v.literal('concluido'), + v.literal('falhou') ); // Status de templates de fluxo export const flowTemplateStatus = v.union( - v.literal("draft"), - v.literal("published"), - v.literal("archived") + v.literal('draft'), + v.literal('published'), + v.literal('archived') ); export type FlowTemplateStatus = Infer; // Status de instâncias de fluxo export const flowInstanceStatus = v.union( - v.literal("active"), - v.literal("completed"), - v.literal("cancelled") + v.literal('active'), + v.literal('completed'), + v.literal('cancelled') ); export type FlowInstanceStatus = Infer; // Status de passos de instância de fluxo export const flowInstanceStepStatus = v.union( - v.literal("pending"), - v.literal("in_progress"), - v.literal("completed"), - v.literal("blocked") + v.literal('pending'), + v.literal('in_progress'), + v.literal('completed'), + v.literal('blocked') ); export type FlowInstanceStepStatus = Infer; export const situacaoContrato = v.union( - v.literal("em_execucao"), - v.literal("rescendido"), - v.literal("aguardando_assinatura"), - v.literal("finalizado") + v.literal('em_execucao'), + v.literal('rescendido'), + v.literal('aguardando_assinatura'), + v.literal('finalizado') ); export default defineSchema({ - // Setores da organização - setores: defineTable({ - nome: v.string(), - sigla: v.string(), - criadoPor: v.id("usuarios"), - createdAt: v.number(), - }) - .index("by_nome", ["nome"]) - .index("by_sigla", ["sigla"]), - - // Relação muitos-para-muitos entre funcionários e setores - funcionarioSetores: defineTable({ - funcionarioId: v.id("funcionarios"), - setorId: v.id("setores"), - createdAt: v.number(), - }) - .index("by_funcionarioId", ["funcionarioId"]) - .index("by_setorId", ["setorId"]) - .index("by_funcionarioId_and_setorId", ["funcionarioId", "setorId"]), - - // Templates de fluxo - flowTemplates: defineTable({ - name: v.string(), - description: v.optional(v.string()), - status: flowTemplateStatus, - createdBy: v.id("usuarios"), - createdAt: v.number(), - }) - .index("by_status", ["status"]) - .index("by_createdBy", ["createdBy"]), - - // Passos de template de fluxo - flowSteps: defineTable({ - flowTemplateId: v.id("flowTemplates"), - name: v.string(), - description: v.optional(v.string()), - position: v.number(), - expectedDuration: v.number(), // em dias - setorId: v.id("setores"), - defaultAssigneeId: v.optional(v.id("usuarios")), - requiredDocuments: v.optional(v.array(v.string())), - }) - .index("by_flowTemplateId", ["flowTemplateId"]) - .index("by_flowTemplateId_and_position", ["flowTemplateId", "position"]), - - // Instâncias de fluxo - flowInstances: defineTable({ - flowTemplateId: v.id("flowTemplates"), - contratoId: v.optional(v.id("contratos")), - managerId: v.id("usuarios"), - status: flowInstanceStatus, - startedAt: v.number(), - finishedAt: v.optional(v.number()), - currentStepId: v.optional(v.id("flowInstanceSteps")), - }) - .index("by_flowTemplateId", ["flowTemplateId"]) - .index("by_contratoId", ["contratoId"]) - .index("by_managerId", ["managerId"]) - .index("by_status", ["status"]), - - // Passos de instância de fluxo - flowInstanceSteps: defineTable({ - flowInstanceId: v.id("flowInstances"), - flowStepId: v.id("flowSteps"), - setorId: v.id("setores"), - assignedToId: v.optional(v.id("usuarios")), - status: flowInstanceStepStatus, - startedAt: v.optional(v.number()), - finishedAt: v.optional(v.number()), - notes: v.optional(v.string()), - notesUpdatedBy: v.optional(v.id("usuarios")), - notesUpdatedAt: v.optional(v.number()), - dueDate: v.optional(v.number()), - }) - .index("by_flowInstanceId", ["flowInstanceId"]) - .index("by_flowInstanceId_and_status", ["flowInstanceId", "status"]) - .index("by_setorId", ["setorId"]) - .index("by_assignedToId", ["assignedToId"]), - - // Documentos de instância de fluxo - flowInstanceDocuments: defineTable({ - flowInstanceStepId: v.id("flowInstanceSteps"), - uploadedById: v.id("usuarios"), - storageId: v.id("_storage"), - name: v.string(), - uploadedAt: v.number(), - }) - .index("by_flowInstanceStepId", ["flowInstanceStepId"]) - .index("by_uploadedById", ["uploadedById"]), - - // Sub-etapas de fluxo (para templates e instâncias) - flowSubSteps: defineTable({ - flowStepId: v.optional(v.id("flowSteps")), // Para templates - flowInstanceStepId: v.optional(v.id("flowInstanceSteps")), // Para instâncias - name: v.string(), - description: v.optional(v.string()), - status: v.union( - v.literal("pending"), - v.literal("in_progress"), - v.literal("completed"), - v.literal("blocked") - ), - position: v.number(), - createdBy: v.id("usuarios"), - createdAt: v.number(), - }) - .index("by_flowStepId", ["flowStepId"]) - .index("by_flowInstanceStepId", ["flowInstanceStepId"]), - - // Notas de steps e sub-etapas - flowStepNotes: defineTable({ - flowStepId: v.optional(v.id("flowSteps")), - flowInstanceStepId: v.optional(v.id("flowInstanceSteps")), - flowSubStepId: v.optional(v.id("flowSubSteps")), - texto: v.string(), - criadoPor: v.id("usuarios"), - criadoEm: v.number(), - arquivos: v.array(v.id("_storage")), - }) - .index("by_flowStepId", ["flowStepId"]) - .index("by_flowInstanceStepId", ["flowInstanceStepId"]) - .index("by_flowSubStepId", ["flowSubStepId"]), - - contratos: defineTable({ - contratadaId: v.id("empresas"), - objeto: v.string(), - numeroNotaEmpenho: v.string(), - responsavelId: v.id("funcionarios"), - departamento: v.string(), - situacao: situacaoContrato, - numeroProcessoLicitatorio: v.string(), - modalidade: v.string(), - numeroContrato: v.string(), - anoContrato: v.number(), - dataInicioVigencia: v.string(), - dataFimVigencia: v.string(), - nomeFiscal: v.string(), - valorTotal: v.string(), - dataAditivoPrazo: v.optional(v.string()), - diasAvisoVencimento: v.number(), - criadoPor: v.id("usuarios"), - criadoEm: v.number(), - atualizadoEm: v.optional(v.number()), - }) - .index("by_responsavel", ["responsavelId"]) - .index("by_situacao", ["situacao"]) - .index("by_vigencia_inicio", ["dataInicioVigencia"]) - .index("by_vigencia_fim", ["dataFimVigencia"]), - - todos: defineTable({ - text: v.string(), - completed: v.boolean(), - }), - enderecos: defineTable({ - cep: v.string(), - logradouro: v.string(), - numero: v.string(), - complemento: v.optional(v.string()), - bairro: v.string(), - cidade: v.string(), - uf: v.string(), - criadoPor: v.optional(v.id("usuarios")), - atualizadoPor: v.optional(v.id("usuarios")), - }).index("by_cep", ["cep"]), - empresas: defineTable({ - razao_social: v.string(), - nome_fantasia: v.optional(v.string()), - cnpj: v.string(), - telefone: v.string(), - email: v.string(), - descricao: v.optional(v.string()), - enderecoId: v.optional(v.id("enderecos")), - criadoPor: v.optional(v.id("usuarios")), - }) - .index("by_razao_social", ["razao_social"]) - .index("by_cnpj", ["cnpj"]), - contatosEmpresa: defineTable({ - empresaId: v.id("empresas"), - nome: v.string(), - funcao: v.string(), - email: v.string(), - telefone: v.string(), - adicionadoPor: v.optional(v.id("usuarios")), - descricao: v.optional(v.string()), - }) - .index("by_empresa", ["empresaId"]) - .index("by_email", ["email"]), - funcionarios: defineTable({ - // Campos obrigatórios existentes - nome: v.string(), - nascimento: v.string(), - rg: v.string(), - cpf: v.string(), - endereco: v.string(), - cep: v.string(), - cidade: v.string(), - uf: v.string(), - telefone: v.string(), - email: v.string(), - matricula: v.optional(v.string()), - admissaoData: v.optional(v.string()), - desligamentoData: v.optional(v.string()), - simboloId: v.id("simbolos"), - simboloTipo: simboloTipo, - gestorId: v.optional(v.id("usuarios")), - statusFerias: v.optional( - v.union(v.literal("ativo"), v.literal("em_ferias")) - ), - - // Regime de trabalho (para cálculo correto de férias) - regimeTrabalho: v.optional( - v.union( - v.literal("clt"), // CLT - Consolidação das Leis do Trabalho - v.literal("estatutario_pe"), // Servidor Público Estadual de Pernambuco - v.literal("estatutario_federal"), // Servidor Público Federal - v.literal("estatutario_municipal") // Servidor Público Municipal - ) - ), - - // Dados Pessoais Adicionais (opcionais) - nomePai: v.optional(v.string()), - nomeMae: v.optional(v.string()), - naturalidade: v.optional(v.string()), - naturalidadeUF: v.optional(v.string()), - sexo: v.optional( - v.union(v.literal("masculino"), v.literal("feminino"), v.literal("outro")) - ), - estadoCivil: v.optional( - v.union( - v.literal("solteiro"), - v.literal("casado"), - v.literal("divorciado"), - v.literal("viuvo"), - v.literal("uniao_estavel") - ) - ), - nacionalidade: v.optional(v.string()), - - // Documentos Pessoais - rgOrgaoExpedidor: v.optional(v.string()), - rgDataEmissao: v.optional(v.string()), - carteiraProfissionalNumero: v.optional(v.string()), - carteiraProfissionalSerie: v.optional(v.string()), - carteiraProfissionalDataEmissao: v.optional(v.string()), - reservistaNumero: v.optional(v.string()), - reservistaSerie: v.optional(v.string()), - tituloEleitorNumero: v.optional(v.string()), - tituloEleitorZona: v.optional(v.string()), - tituloEleitorSecao: v.optional(v.string()), - pisNumero: v.optional(v.string()), - - // Formação e Saúde - grauInstrucao: v.optional( - v.union( - v.literal("fundamental"), - v.literal("medio"), - v.literal("superior"), - v.literal("pos_graduacao"), - v.literal("mestrado"), - v.literal("doutorado") - ) - ), - formacao: v.optional(v.string()), - formacaoRegistro: v.optional(v.string()), - grupoSanguineo: v.optional( - v.union(v.literal("A"), v.literal("B"), v.literal("AB"), v.literal("O")) - ), - fatorRH: v.optional(v.union(v.literal("positivo"), v.literal("negativo"))), - - // Cargo e Vínculo - descricaoCargo: v.optional(v.string()), - nomeacaoPortaria: v.optional(v.string()), - nomeacaoData: v.optional(v.string()), - nomeacaoDOE: v.optional(v.string()), - pertenceOrgaoPublico: v.optional(v.boolean()), - orgaoOrigem: v.optional(v.string()), - aposentado: v.optional( - v.union(v.literal("nao"), v.literal("funape_ipsep"), v.literal("inss")) - ), - - // Dados Bancários - contaBradescoNumero: v.optional(v.string()), - contaBradescoDV: v.optional(v.string()), - contaBradescoAgencia: v.optional(v.string()), - - // Documentos Anexos (Storage IDs) - certidaoAntecedentesPF: v.optional(v.id("_storage")), - certidaoAntecedentesJFPE: v.optional(v.id("_storage")), - certidaoAntecedentesSDS: v.optional(v.id("_storage")), - certidaoAntecedentesTJPE: v.optional(v.id("_storage")), - certidaoImprobidade: v.optional(v.id("_storage")), - rgFrente: v.optional(v.id("_storage")), - rgVerso: v.optional(v.id("_storage")), - cpfFrente: v.optional(v.id("_storage")), - cpfVerso: v.optional(v.id("_storage")), - situacaoCadastralCPF: v.optional(v.id("_storage")), - tituloEleitorFrente: v.optional(v.id("_storage")), - tituloEleitorVerso: v.optional(v.id("_storage")), - comprovanteVotacao: v.optional(v.id("_storage")), - carteiraProfissionalFrente: v.optional(v.id("_storage")), - carteiraProfissionalVerso: v.optional(v.id("_storage")), - comprovantePIS: v.optional(v.id("_storage")), - certidaoRegistroCivil: v.optional(v.id("_storage")), - certidaoNascimentoDependentes: v.optional(v.id("_storage")), - cpfDependentes: v.optional(v.id("_storage")), - reservistaDoc: v.optional(v.id("_storage")), - comprovanteEscolaridade: v.optional(v.id("_storage")), - comprovanteResidencia: v.optional(v.id("_storage")), - comprovanteContaBradesco: v.optional(v.id("_storage")), - - // Dependentes do funcionário (uploads opcionais) - dependentes: v.optional( - v.array( - v.object({ - parentesco: v.union( - v.literal("filho"), - v.literal("filha"), - v.literal("conjuge"), - v.literal("outro") - ), - nome: v.string(), - cpf: v.string(), - nascimento: v.string(), - documentoId: v.optional(v.id("_storage")), - // Benefícios/declarações por dependente - salarioFamilia: v.optional(v.boolean()), - impostoRenda: v.optional(v.boolean()), - }) - ) - ), - - // Declarações (Storage IDs) - declaracaoAcumulacaoCargo: v.optional(v.id("_storage")), - declaracaoDependentesIR: v.optional(v.id("_storage")), - declaracaoIdoneidade: v.optional(v.id("_storage")), - termoNepotismo: v.optional(v.id("_storage")), - termoOpcaoRemuneracao: v.optional(v.id("_storage")), - }) - .index("by_matricula", ["matricula"]) - .index("by_nome", ["nome"]) - .index("by_simboloId", ["simboloId"]) - .index("by_simboloTipo", ["simboloTipo"]) - .index("by_cpf", ["cpf"]) - .index("by_rg", ["rg"]) - .index("by_gestor", ["gestorId"]), - - atestados: defineTable({ - funcionarioId: v.id("funcionarios"), - tipo: v.union( - v.literal("atestado_medico"), - v.literal("declaracao_comparecimento") - ), - dataInicio: v.string(), - dataFim: v.string(), - cid: v.optional(v.string()), // Apenas para atestado médico - observacoes: v.optional(v.string()), - documentoId: v.optional(v.id("_storage")), - criadoPor: v.id("usuarios"), - criadoEm: v.number(), - }) - .index("by_funcionario", ["funcionarioId"]) - .index("by_tipo", ["tipo"]) - .index("by_data_inicio", ["dataInicio"]) - .index("by_funcionario_and_tipo", ["funcionarioId", "tipo"]), - - licencas: defineTable({ - funcionarioId: v.id("funcionarios"), - tipo: v.union(v.literal("maternidade"), v.literal("paternidade")), - dataInicio: v.string(), - dataFim: v.string(), - documentoId: v.optional(v.id("_storage")), - observacoes: v.optional(v.string()), - licencaOriginalId: v.optional(v.id("licencas")), // Para prorrogações - ehProrrogacao: v.boolean(), - criadoPor: v.id("usuarios"), - criadoEm: v.number(), - }) - .index("by_funcionario", ["funcionarioId"]) - .index("by_tipo", ["tipo"]) - .index("by_data_inicio", ["dataInicio"]) - .index("by_licenca_original", ["licencaOriginalId"]) - .index("by_funcionario_and_tipo", ["funcionarioId", "tipo"]), - - ferias: defineTable({ - funcionarioId: v.id("funcionarios"), - anoReferencia: v.number(), - dataInicio: v.string(), - dataFim: v.string(), - diasFerias: v.number(), - status: v.union( - v.literal("aguardando_aprovacao"), - v.literal("aprovado"), - v.literal("reprovado"), - v.literal("data_ajustada_aprovada"), - v.literal("EmFérias"), - v.literal("Cancelado_RH") - ), - gestorId: v.optional(v.id("usuarios")), - observacao: v.optional(v.string()), - motivoReprovacao: v.optional(v.string()), - dataAprovacao: v.optional(v.number()), - dataReprovacao: v.optional(v.number()), - diasAbono: v.number(), - historicoAlteracoes: v.optional( - v.array( - v.object({ - data: v.number(), - usuarioId: v.id("usuarios"), - acao: v.string(), - }) - ) - ), - }) - .index("by_funcionario", ["funcionarioId"]) - .index("by_funcionario_and_ano", ["funcionarioId", "anoReferencia"]) - .index("by_funcionario_and_status", ["funcionarioId", "status"]) - .index("by_status", ["status"]) - .index("by_ano", ["anoReferencia"]), - - notificacoesFerias: defineTable({ - destinatarioId: v.id("usuarios"), - feriasId: v.id("ferias"), - tipo: v.union( - v.literal("nova_solicitacao"), - v.literal("aprovado"), - v.literal("reprovado"), - v.literal("data_ajustada") - ), - lida: v.boolean(), - mensagem: v.string(), - }) - .index("by_destinatario", ["destinatarioId"]) - .index("by_destinatario_and_lida", ["destinatarioId", "lida"]), - - // Solicitações de Ausências - solicitacoesAusencias: defineTable({ - funcionarioId: v.id("funcionarios"), - dataInicio: v.string(), - dataFim: v.string(), - motivo: v.string(), - status: v.union( - v.literal("aguardando_aprovacao"), - v.literal("aprovado"), - v.literal("reprovado") - ), - gestorId: v.optional(v.id("usuarios")), - dataAprovacao: v.optional(v.number()), - dataReprovacao: v.optional(v.number()), - motivoReprovacao: v.optional(v.string()), - observacao: v.optional(v.string()), - criadoEm: v.number(), - }) - .index("by_funcionario", ["funcionarioId"]) - .index("by_status", ["status"]) - .index("by_funcionario_and_status", ["funcionarioId", "status"]), - - notificacoesAusencias: defineTable({ - destinatarioId: v.id("usuarios"), - solicitacaoAusenciaId: v.id("solicitacoesAusencias"), - tipo: v.union( - v.literal("nova_solicitacao"), - v.literal("aprovado"), - v.literal("reprovado") - ), - lida: v.boolean(), - mensagem: v.string(), - }) - .index("by_destinatario", ["destinatarioId"]) - .index("by_destinatario_and_lida", ["destinatarioId", "lida"]), - - - times: defineTable({ - nome: v.string(), - descricao: v.optional(v.string()), - gestorId: v.id("usuarios"), - gestorSuperiorId: v.optional(v.id("usuarios")), - ativo: v.boolean(), - cor: v.optional(v.string()), // Cor para identificação visual - }) - .index("by_gestor", ["gestorId"]) - .index("by_gestor_superior", ["gestorSuperiorId"]), - - timesMembros: defineTable({ - timeId: v.id("times"), - funcionarioId: v.id("funcionarios"), - dataEntrada: v.number(), - dataSaida: v.optional(v.number()), - ativo: v.boolean(), - }) - .index("by_time", ["timeId"]) - .index("by_funcionario", ["funcionarioId"]) - .index("by_time_and_ativo", ["timeId", "ativo"]), - - cursos: defineTable({ - funcionarioId: v.id("funcionarios"), - descricao: v.string(), - data: v.string(), - certificadoId: v.optional(v.id("_storage")), - }).index("by_funcionario", ["funcionarioId"]), - - simbolos: defineTable({ - nome: v.string(), - tipo: simboloTipo, - descricao: v.string(), - vencValor: v.string(), - repValor: v.string(), - valor: v.string(), - }), - - // Sistema de Autenticação e Controle de Acesso - usuarios: defineTable({ - authId: v.string(), - nome: v.string(), - email: v.string(), - funcionarioId: v.optional(v.id("funcionarios")), - roleId: v.id("roles"), - ativo: v.boolean(), - primeiroAcesso: v.boolean(), - ultimoAcesso: v.optional(v.number()), - criadoEm: v.number(), - atualizadoEm: v.number(), - - // Controle de Bloqueio e Segurança - bloqueado: v.optional(v.boolean()), - motivoBloqueio: v.optional(v.string()), - dataBloqueio: v.optional(v.number()), - tentativasLogin: v.optional(v.number()), // contador de tentativas falhas - ultimaTentativaLogin: v.optional(v.number()), // timestamp da última tentativa - - // Campos de Chat e Perfil - - fotoPerfil: v.optional(v.id("_storage")), - avatar: v.optional(v.string()), // URL do avatar gerado (ex: DiceBear) - setor: v.optional(v.string()), - statusMensagem: v.optional(v.string()), // max 100 chars - statusPresenca: v.optional( - v.union( - v.literal("online"), - v.literal("offline"), - v.literal("ausente"), - v.literal("externo"), - v.literal("em_reuniao") - ) - ), - ultimaAtividade: v.optional(v.number()), // timestamp - notificacoesAtivadas: v.optional(v.boolean()), - somNotificacao: v.optional(v.boolean()), - temaPreferido: v.optional(v.string()), // tema de aparência escolhido pelo usuário - }) - .index("by_email", ["email"]) - .index("by_role", ["roleId"]) - .index("by_ativo", ["ativo"]) - .index("by_status_presenca", ["statusPresenca"]) - .index("by_bloqueado", ["bloqueado"]) - .index("by_funcionarioId", ["funcionarioId"]) - .index("authId", ["authId"]), - - roles: defineTable({ - nome: v.string(), // "admin", "ti_master", "ti_usuario", "usuario_avancado", "usuario" - descricao: v.string(), - nivel: v.number(), // 0 = admin, 1 = ti_master, 2 = ti_usuario, 3+ = customizado - setor: v.optional(v.string()), // "ti", "rh", "financeiro", etc. - customizado: v.optional(v.boolean()), // se é um perfil customizado criado por TI_MASTER - criadoPor: v.optional(v.id("usuarios")), // usuário TI_MASTER que criou este perfil - editavel: v.optional(v.boolean()), // se pode ser editado (false para roles fixas) - }) - .index("by_nome", ["nome"]) - .index("by_nivel", ["nivel"]) - .index("by_setor", ["setor"]) - .index("by_customizado", ["customizado"]), - - permissoes: defineTable({ - nome: v.string(), // "funcionarios.criar", "simbolos.editar", etc. - descricao: v.string(), - recurso: v.string(), // "funcionarios", "simbolos", "usuarios", etc. - acao: v.string(), // "criar", "ler", "editar", "excluir" - }) - .index("by_recurso", ["recurso"]) - .index("by_recurso_e_acao", ["recurso", "acao"]) - .index("by_nome", ["nome"]), - - rolePermissoes: defineTable({ - roleId: v.id("roles"), - permissaoId: v.id("permissoes"), - }) - .index("by_role", ["roleId"]) - .index("by_permissao", ["permissaoId"]), - - sessoes: defineTable({ - usuarioId: v.id("usuarios"), - token: v.string(), - ipAddress: v.optional(v.string()), - userAgent: v.optional(v.string()), - criadoEm: v.number(), - expiraEm: v.number(), - ativo: v.boolean(), - }) - .index("by_usuario", ["usuarioId"]) - .index("by_token", ["token"]) - .index("by_ativo", ["ativo"]) - .index("by_expiracao", ["expiraEm"]), - - logsAcesso: defineTable({ - usuarioId: v.id("usuarios"), - tipo: v.union( - v.literal("login"), - v.literal("logout"), - v.literal("acesso_negado"), - v.literal("senha_alterada"), - v.literal("sessao_expirada") - ), - ipAddress: v.optional(v.string()), - userAgent: v.optional(v.string()), - detalhes: v.optional(v.string()), - timestamp: v.number(), - }) - .index("by_usuario", ["usuarioId"]) - .index("by_tipo", ["tipo"]) - .index("by_timestamp", ["timestamp"]), - - // Logs de Login Detalhados - logsLogin: defineTable({ - usuarioId: v.optional(v.id("usuarios")), // pode ser null se falha antes de identificar usuário - matriculaOuEmail: v.string(), // tentativa de login - sucesso: v.boolean(), - motivoFalha: v.optional(v.string()), // "senha_incorreta", "usuario_bloqueado", "usuario_inexistente" - ipAddress: v.optional(v.string()), - userAgent: v.optional(v.string()), - device: v.optional(v.string()), - browser: v.optional(v.string()), - sistema: v.optional(v.string()), - timestamp: v.number(), - }) - .index("by_usuario", ["usuarioId"]) - .index("by_sucesso", ["sucesso"]) - .index("by_timestamp", ["timestamp"]) - .index("by_ip", ["ipAddress"]), - - // Logs de Atividades - logsAtividades: defineTable({ - usuarioId: v.id("usuarios"), - acao: v.string(), // "criar", "editar", "excluir", "bloquear", "desbloquear", etc. - recurso: v.string(), // "funcionarios", "simbolos", "usuarios", "perfis", etc. - recursoId: v.optional(v.string()), // ID do recurso afetado - detalhes: v.optional(v.string()), // JSON com detalhes da ação - timestamp: v.number(), - }) - .index("by_usuario", ["usuarioId"]) - .index("by_acao", ["acao"]) - .index("by_recurso", ["recurso"]) - .index("by_timestamp", ["timestamp"]) - .index("by_recurso_id", ["recurso", "recursoId"]), - - // Histórico de Bloqueios - bloqueiosUsuarios: defineTable({ - usuarioId: v.id("usuarios"), - motivo: v.string(), - bloqueadoPor: v.id("usuarios"), // ID do TI_MASTER que bloqueou - dataInicio: v.number(), - dataFim: v.optional(v.number()), // quando foi desbloqueado - desbloqueadoPor: v.optional(v.id("usuarios")), - ativo: v.boolean(), // se é o bloqueio atual ativo - }) - .index("by_usuario", ["usuarioId"]) - .index("by_bloqueado_por", ["bloqueadoPor"]) - .index("by_ativo", ["ativo"]) - .index("by_data_inicio", ["dataInicio"]), - - // Perfis Customizados - - // Templates de Mensagens - templatesMensagens: defineTable({ - codigo: v.string(), // "USUARIO_BLOQUEADO", "SENHA_RESETADA", etc. - nome: v.string(), - tipo: v.union( - v.literal("sistema"), // predefinido, não editável - v.literal("customizado") // criado por TI_MASTER - ), - titulo: v.string(), - corpo: v.string(), // pode ter variáveis {{variavel}} - variaveis: v.optional(v.array(v.string())), // ["motivo", "senha", etc.] - criadoPor: v.optional(v.id("usuarios")), - criadoEm: v.number(), - }) - .index("by_codigo", ["codigo"]) - .index("by_tipo", ["tipo"]) - .index("by_criado_por", ["criadoPor"]), - - // Configuração de Email/SMTP - configuracaoEmail: defineTable({ - servidor: v.string(), // smtp.gmail.com - porta: v.number(), // 587, 465, etc. - usuario: v.string(), - senhaHash: v.string(), // senha criptografada reversível (AES-GCM) - necessário para descriptografar e usar no SMTP - emailRemetente: v.string(), - nomeRemetente: v.string(), - usarSSL: v.boolean(), - usarTLS: v.boolean(), - ativo: v.boolean(), - testadoEm: v.optional(v.number()), - configuradoPor: v.id("usuarios"), - atualizadoEm: v.number(), - }).index("by_ativo", ["ativo"]), - - // Configuração de Jitsi Meet - configuracaoJitsi: defineTable({ - domain: v.string(), // Domínio do servidor Jitsi (ex: "localhost:8443" ou "meet.example.com") - appId: v.string(), // ID da aplicação Jitsi - roomPrefix: v.string(), // Prefixo para nomes de salas - useHttps: v.boolean(), // Usar HTTPS - acceptSelfSignedCert: v.optional(v.boolean()), // Aceitar certificados autoassinados (útil para desenvolvimento) - // Configurações SSH/Docker para configuração automática do servidor - sshHost: v.optional(v.string()), // Host SSH para acesso ao servidor Docker (ex: "192.168.1.100" ou "servidor.local") - sshPort: v.optional(v.number()), // Porta SSH (padrão: 22) - sshUsername: v.optional(v.string()), // Usuário SSH - sshPasswordHash: v.optional(v.string()), // Hash da senha SSH (criptografada) - sshKeyPath: v.optional(v.string()), // Caminho para chave SSH (alternativa à senha) - dockerComposePath: v.optional(v.string()), // Caminho do docker-compose.yml (ex: "/home/user/jitsi-docker") - jitsiConfigPath: v.optional(v.string()), // Caminho base das configurações Jitsi (ex: "~/.jitsi-meet-cfg") - ativo: v.boolean(), // Configuração ativa - testadoEm: v.optional(v.number()), // Timestamp do último teste de conexão - configuradoEm: v.optional(v.number()), // Timestamp da última configuração do servidor Docker - configuradoNoServidor: v.optional(v.boolean()), // Indica se a configuração foi aplicada no servidor - configuradoNoServidorEm: v.optional(v.number()), // Timestamp de quando foi configurado no servidor - configuradoPor: v.id("usuarios"), // Usuário que configurou - atualizadoEm: v.number(), // Timestamp de atualização - }).index("by_ativo", ["ativo"]), - - // Fila de Emails - notificacoesEmail: defineTable({ - destinatario: v.string(), // email - destinatarioId: v.optional(v.id("usuarios")), - assunto: v.string(), - corpo: v.string(), // HTML ou texto - templateId: v.optional(v.id("templatesMensagens")), - status: v.union( - v.literal("pendente"), - v.literal("enviando"), - v.literal("enviado"), - v.literal("falha") - ), - tentativas: v.number(), - ultimaTentativa: v.optional(v.number()), - erroDetalhes: v.optional(v.string()), - enviadoPor: v.id("usuarios"), - criadoEm: v.number(), - enviadoEm: v.optional(v.number()), - agendadaPara: v.optional(v.number()), // timestamp para agendamento - }) - .index("by_status", ["status"]) - .index("by_destinatario", ["destinatarioId"]) - .index("by_enviado_por", ["enviadoPor"]) - .index("by_criado_em", ["criadoEm"]) - .index("by_agendamento", ["agendadaPara"]), - - configuracaoAcesso: defineTable({ - chave: v.string(), // "sessao_duracao", "max_tentativas_login", etc. - valor: v.string(), - descricao: v.string(), - }).index("by_chave", ["chave"]), - - // Rate Limiting de Emails - rateLimitEmails: defineTable({ - remetenteId: v.id("usuarios"), - timestamp: v.number(), - contador: v.number(), // quantidade de emails enviados neste período - periodo: v.union( - v.literal("minuto"), // último minuto - v.literal("hora") // última hora - ), - }) - .index("by_remetente_periodo", ["remetenteId", "periodo", "timestamp"]) - .index("by_timestamp", ["timestamp"]), - - // Sistema de Chat - conversas: defineTable({ - tipo: v.union( - v.literal("individual"), - v.literal("grupo"), - v.literal("sala_reuniao") - ), - nome: v.optional(v.string()), // nome do grupo/sala - - participantes: v.array(v.id("usuarios")), // IDs dos participantes - administradores: v.optional(v.array(v.id("usuarios"))), // IDs dos administradores (apenas para sala_reuniao) - ultimaMensagem: v.optional(v.string()), - ultimaMensagemTimestamp: v.optional(v.number()), - ultimaMensagemRemetenteId: v.optional(v.id("usuarios")), // ID do remetente da última mensagem - criadoPor: v.id("usuarios"), - criadoEm: v.number(), - }) - .index("by_criado_por", ["criadoPor"]) - .index("by_tipo", ["tipo"]) - .index("by_ultima_mensagem", ["ultimaMensagemTimestamp"]), - - mensagens: defineTable({ - conversaId: v.id("conversas"), - remetenteId: v.id("usuarios"), - tipo: v.union( - v.literal("texto"), - v.literal("arquivo"), - v.literal("imagem") - ), - conteudo: v.string(), // texto ou nome do arquivo - conteudoBusca: v.optional(v.string()), // versão normalizada para busca - arquivoId: v.optional(v.id("_storage")), - arquivoNome: v.optional(v.string()), - arquivoTamanho: v.optional(v.number()), - arquivoTipo: v.optional(v.string()), - linkPreview: v.optional( - v.object({ - url: v.string(), - titulo: v.optional(v.string()), - descricao: v.optional(v.string()), - imagem: v.optional(v.string()), - site: v.optional(v.string()), - }) - ), - reagiuPor: v.optional( - v.array( - v.object({ - usuarioId: v.id("usuarios"), - emoji: v.string(), - }) - ) - ), - mencoes: v.optional(v.array(v.id("usuarios"))), - respostaPara: v.optional(v.id("mensagens")), // ID da mensagem que está respondendo - agendadaPara: v.optional(v.number()), // timestamp - enviadaEm: v.number(), - editadaEm: v.optional(v.number()), - deletada: v.optional(v.boolean()), - lidaPor: v.optional(v.array(v.id("usuarios"))), // IDs dos usuários que leram a mensagem - }) - .index("by_conversa", ["conversaId", "enviadaEm"]) - .index("by_remetente", ["remetenteId"]) - .index("by_agendamento", ["agendadaPara"]) - .index("by_resposta", ["respostaPara"]), - - leituras: defineTable({ - conversaId: v.id("conversas"), - usuarioId: v.id("usuarios"), - ultimaMensagemLida: v.id("mensagens"), - lidaEm: v.number(), - }) - .index("by_conversa_usuario", ["conversaId", "usuarioId"]) - .index("by_usuario", ["usuarioId"]), - - // Sistema de Chamadas de Áudio/Vídeo - chamadas: defineTable({ - conversaId: v.id("conversas"), - tipo: v.union(v.literal("audio"), v.literal("video")), - roomName: v.string(), // Nome único da sala Jitsi - criadoPor: v.id("usuarios"), // Anfitrião/criador - participantes: v.array(v.id("usuarios")), - status: v.union( - v.literal("aguardando"), - v.literal("em_andamento"), - v.literal("finalizada"), - v.literal("cancelada") - ), - iniciadaEm: v.optional(v.number()), - finalizadaEm: v.optional(v.number()), - duracaoSegundos: v.optional(v.number()), - gravando: v.boolean(), - gravacaoIniciadaPor: v.optional(v.id("usuarios")), - gravacaoIniciadaEm: v.optional(v.number()), - gravacaoFinalizadaEm: v.optional(v.number()), - configuracoes: v.optional(v.object({ - audioHabilitado: v.boolean(), - videoHabilitado: v.boolean(), - participantesConfig: v.optional(v.array(v.object({ - usuarioId: v.id("usuarios"), - audioHabilitado: v.boolean(), - videoHabilitado: v.boolean(), - forcadoPeloAnfitriao: v.optional(v.boolean()), // Se foi forçado pelo anfitrião - }))) - })), - criadoEm: v.number(), - }) - .index("by_conversa", ["conversaId", "status"]) - .index("by_criado_por", ["criadoPor"]) - .index("by_status", ["status"]) - .index("by_room_name", ["roomName"]), - - notificacoes: defineTable({ - usuarioId: v.id("usuarios"), - tipo: v.union( - v.literal("nova_mensagem"), - v.literal("mencao"), - v.literal("grupo_criado"), - v.literal("adicionado_grupo"), - v.literal("alerta_seguranca"), - v.literal("etapa_fluxo_concluida") - ), - conversaId: v.optional(v.id("conversas")), - mensagemId: v.optional(v.id("mensagens")), - remetenteId: v.optional(v.id("usuarios")), - titulo: v.string(), - descricao: v.string(), - lida: v.boolean(), - criadaEm: v.number(), - }) - .index("by_usuario", ["usuarioId", "lida", "criadaEm"]) - .index("by_usuario_lida", ["usuarioId", "lida"]), - - digitando: defineTable({ - conversaId: v.id("conversas"), - usuarioId: v.id("usuarios"), - iniciouEm: v.number(), - }) - .index("by_conversa", ["conversaId", "iniciouEm"]) - .index("by_usuario", ["usuarioId"]), - - // Push Notifications - pushSubscriptions: defineTable({ - usuarioId: v.id("usuarios"), - endpoint: v.string(), // URL do serviço de push - keys: v.object({ - p256dh: v.string(), // Chave pública - auth: v.string(), // Chave de autenticação - }), - userAgent: v.optional(v.string()), - criadoEm: v.number(), - ultimaAtividade: v.number(), - ativo: v.boolean(), - }) - .index("by_usuario", ["usuarioId", "ativo"]) - .index("by_endpoint", ["endpoint"]), - - // Preferências de Notificação por Conversa - preferenciasNotificacaoConversa: defineTable({ - usuarioId: v.id("usuarios"), - conversaId: v.id("conversas"), - pushAtivado: v.boolean(), // Receber push notifications - emailAtivado: v.boolean(), // Receber emails quando offline - somAtivado: v.boolean(), // Tocar som - silenciado: v.boolean(), // Silenciar completamente - apenasMencoes: v.boolean(), // Notificar apenas quando mencionado - criadoEm: v.number(), - atualizadoEm: v.number(), - }) - .index("by_usuario_conversa", ["usuarioId", "conversaId"]) - .index("by_conversa", ["conversaId"]), - - // Tabelas de Monitoramento do Sistema - systemMetrics: defineTable({ - timestamp: v.number(), - // Métricas de Sistema - cpuUsage: v.optional(v.number()), - memoryUsage: v.optional(v.number()), - networkLatency: v.optional(v.number()), - storageUsed: v.optional(v.number()), - // Métricas de Aplicação - usuariosOnline: v.optional(v.number()), - mensagensPorMinuto: v.optional(v.number()), - tempoRespostaMedio: v.optional(v.number()), - errosCount: v.optional(v.number()), - }).index("by_timestamp", ["timestamp"]), - - alertConfigurations: defineTable({ - metricName: v.string(), - threshold: v.number(), - operator: v.union( - v.literal(">"), - v.literal("<"), - v.literal(">="), - v.literal("<="), - v.literal("==") - ), - enabled: v.boolean(), - notifyByEmail: v.boolean(), - notifyByChat: v.boolean(), - createdBy: v.id("usuarios"), - lastModified: v.number(), - }).index("by_enabled", ["enabled"]), - - alertHistory: defineTable({ - configId: v.id("alertConfigurations"), - metricName: v.string(), - metricValue: v.number(), - threshold: v.number(), - timestamp: v.number(), - status: v.union(v.literal("triggered"), v.literal("resolved")), - notificationsSent: v.object({ - email: v.boolean(), - chat: v.boolean(), - }), - }) - .index("by_timestamp", ["timestamp"]) - .index("by_status", ["status"]) - .index("by_config", ["configId", "timestamp"]), - - tickets: defineTable({ - numero: v.string(), - titulo: v.string(), - descricao: v.string(), - tipo: v.union( - v.literal("reclamacao"), - v.literal("elogio"), - v.literal("sugestao"), - v.literal("chamado") - ), - categoria: v.optional(v.string()), - status: v.union( - v.literal("aberto"), - v.literal("em_andamento"), - v.literal("aguardando_usuario"), - v.literal("resolvido"), - v.literal("encerrado"), - v.literal("cancelado") - ), - prioridade: v.union( - v.literal("baixa"), - v.literal("media"), - v.literal("alta"), - v.literal("critica") - ), - solicitanteId: v.id("usuarios"), - solicitanteNome: v.string(), - solicitanteEmail: v.string(), - responsavelId: v.optional(v.id("usuarios")), - setorResponsavel: v.optional(v.string()), - slaConfigId: v.optional(v.id("slaConfigs")), - conversaId: v.optional(v.id("conversas")), - prazoResposta: v.optional(v.number()), - prazoConclusao: v.optional(v.number()), - prazoEncerramento: v.optional(v.number()), - timeline: v.optional( - v.array( - v.object({ - etapa: v.string(), - status: v.union( - v.literal("pendente"), - v.literal("em_andamento"), - v.literal("concluido"), - v.literal("vencido") - ), - prazo: v.optional(v.number()), - concluidoEm: v.optional(v.number()), - observacao: v.optional(v.string()), - }) - ) - ), - alertasEmitidos: v.optional( - v.array( - v.object({ - tipo: v.union( - v.literal("resposta"), - v.literal("conclusao"), - v.literal("encerramento") - ), - emitidoEm: v.number(), - }) - ) - ), - anexos: v.optional( - v.array( - v.object({ - arquivoId: v.id("_storage"), - nome: v.optional(v.string()), - tipo: v.optional(v.string()), - tamanho: v.optional(v.number()), - }) - ) - ), - tags: v.optional(v.array(v.string())), - canalOrigem: v.optional(v.string()), - ultimaInteracaoEm: v.number(), - criadoEm: v.number(), - atualizadoEm: v.number(), - }) - .index("by_numero", ["numero"]) - .index("by_status", ["status"]) - .index("by_solicitante", ["solicitanteId", "status"]) - .index("by_responsavel", ["responsavelId", "status"]) - .index("by_setor", ["setorResponsavel", "status"]), - - ticketInteractions: defineTable({ - ticketId: v.id("tickets"), - autorId: v.optional(v.id("usuarios")), - origem: v.union( - v.literal("usuario"), - v.literal("ti"), - v.literal("sistema") - ), - tipo: v.union( - v.literal("mensagem"), - v.literal("status"), - v.literal("anexo"), - v.literal("alerta") - ), - conteudo: v.string(), - anexos: v.optional( - v.array( - v.object({ - arquivoId: v.id("_storage"), - nome: v.optional(v.string()), - tipo: v.optional(v.string()), - tamanho: v.optional(v.number()), - }) - ) - ), - statusAnterior: v.optional( - v.union( - v.literal("aberto"), - v.literal("em_andamento"), - v.literal("aguardando_usuario"), - v.literal("resolvido"), - v.literal("encerrado"), - v.literal("cancelado") - ) - ), - statusNovo: v.optional( - v.union( - v.literal("aberto"), - v.literal("em_andamento"), - v.literal("aguardando_usuario"), - v.literal("resolvido"), - v.literal("encerrado"), - v.literal("cancelado") - ) - ), - visibilidade: v.union( - v.literal("publico"), - v.literal("interno") - ), - criadoEm: v.number(), - }) - .index("by_ticket", ["ticketId"]) - .index("by_ticket_type", ["ticketId", "tipo"]) - .index("by_autor", ["autorId"]), - - slaConfigs: defineTable({ - nome: v.string(), - descricao: v.optional(v.string()), - prioridade: v.optional( - v.union( - v.literal("baixa"), - v.literal("media"), - v.literal("alta"), - v.literal("critica") - ) - ), - tempoRespostaHoras: v.number(), - tempoConclusaoHoras: v.number(), - tempoEncerramentoHoras: v.optional(v.number()), - alertaAntecedenciaHoras: v.number(), - ativo: v.boolean(), - criadoPor: v.id("usuarios"), - atualizadoPor: v.optional(v.id("usuarios")), - criadoEm: v.number(), - atualizadoEm: v.number(), - }) - .index("by_ativo", ["ativo"]) - .index("by_prioridade", ["prioridade", "ativo"]) - .index("by_nome", ["nome"]), - - ticketAssignments: defineTable({ - ticketId: v.id("tickets"), - responsavelId: v.id("usuarios"), - atribuidoPor: v.id("usuarios"), - motivo: v.optional(v.string()), - ativo: v.boolean(), - criadoEm: v.number(), - encerradoEm: v.optional(v.number()), - }) - .index("by_ticket", ["ticketId", "ativo"]) - .index("by_responsavel", ["responsavelId", "ativo"]), - - // Sistema de Segurança Cibernética - networkSensors: defineTable({ - nome: v.string(), - tipo: sensorSegurancaTipo, - status: sensorSegurancaStatus, - escopo: v.optional(v.string()), - ipMonitorado: v.optional(v.string()), - hostname: v.optional(v.string()), - regioes: v.optional(v.array(v.string())), - portasMonitoradas: v.optional(v.array(v.number())), - protocolos: v.optional(v.array(v.string())), - capacidades: v.optional(v.array(v.string())), - ultimaSincronizacao: v.number(), - ultimoHeartbeat: v.optional(v.number()), - latenciaMs: v.optional(v.number()), - errosConsecutivos: v.optional(v.number()), - agenteVersao: v.optional(v.string()), - notas: v.optional(v.string()), - }) - .index("by_tipo", ["tipo"]) - .index("by_status", ["status"]) - .index("by_hostname", ["hostname"]), - - ipReputation: defineTable({ - indicador: v.string(), - categoria: v.union( - v.literal("ip"), - v.literal("dominio"), - v.literal("hash"), - v.literal("email") - ), - reputacao: v.number(), // -100 (malicioso) até 100 (confiável) - severidadeMax: severidadeSeguranca, - whitelist: v.boolean(), - blacklist: v.boolean(), - ocorrencias: v.number(), - primeiroRegistro: v.number(), - ultimoRegistro: v.number(), - bloqueadoAte: v.optional(v.number()), - origem: v.optional(v.string()), - comentarios: v.optional(v.string()), - classificacoes: v.optional(v.array(v.string())), - ultimaAcaoId: v.optional(v.id("incidentActions")), - }) - .index("by_indicador", ["indicador"]) - .index("by_reputacao", ["reputacao"]) - .index("by_blacklist", ["blacklist"]) - .index("by_whitelist", ["whitelist"]), - - portRules: defineTable({ - porta: v.number(), - protocolo: v.union( - v.literal("tcp"), - v.literal("udp"), - v.literal("icmp"), - v.literal("quic"), - v.literal("any") - ), - acao: v.union( - v.literal("permitir"), - v.literal("bloquear"), - v.literal("monitorar"), - v.literal("rate_limit") - ), - temporario: v.boolean(), - severidadeMin: severidadeSeguranca, - duracaoSegundos: v.optional(v.number()), - expiraEm: v.optional(v.number()), - criadoPor: v.id("usuarios"), - atualizadoPor: v.optional(v.id("usuarios")), - criadoEm: v.number(), - atualizadoEm: v.number(), - notas: v.optional(v.string()), - tags: v.optional(v.array(v.string())), - listaReferencia: v.optional(v.id("ipReputation")), - }) - .index("by_porta_protocolo", ["porta", "protocolo"]) - .index("by_acao", ["acao"]) - .index("by_expiracao", ["expiraEm"]), - - threatIntelFeeds: defineTable({ - nomeFonte: v.string(), - tipo: threatIntelTipo, - formato: threatIntelFormato, - url: v.optional(v.string()), - ativo: v.boolean(), - prioridade: v.union( - v.literal("baixa"), - v.literal("media"), - v.literal("alta"), - v.literal("critica") - ), - ultimaSincronizacao: v.optional(v.number()), - entradasProcessadas: v.optional(v.number()), - errosConsecutivos: v.optional(v.number()), - autenticacaoNecessaria: v.optional(v.boolean()), - configuracao: v.optional( - v.object({ - tokenId: v.optional(v.id("_storage")), - escopo: v.optional(v.string()), - }) - ), - criadoPor: v.id("usuarios"), - atualizadoPor: v.optional(v.id("usuarios")), - criadoEm: v.number(), - atualizadoEm: v.number(), - }) - .index("by_tipo", ["tipo"]) - .index("by_ativo", ["ativo"]) - .index("by_prioridade", ["prioridade"]), - - securityEvents: defineTable({ - referencia: v.string(), - timestamp: v.number(), - tipoAtaque: ataqueCiberneticoTipo, - severidade: severidadeSeguranca, - status: statusEventoSeguranca, - descricao: v.string(), - origemIp: v.optional(v.string()), - origemRegiao: v.optional(v.string()), - origemAsn: v.optional(v.string()), - destinoIp: v.optional(v.string()), - destinoPorta: v.optional(v.number()), - protocolo: v.optional(v.string()), - transporte: v.optional(v.string()), - sensorId: v.optional(v.id("networkSensors")), - detectadoPor: v.optional(v.string()), - mitreTechnique: v.optional(v.string()), - geolocalizacao: v.optional( - v.object({ - pais: v.optional(v.string()), - regiao: v.optional(v.string()), - cidade: v.optional(v.string()), - latitude: v.optional(v.number()), - longitude: v.optional(v.number()), - }) - ), - fingerprint: v.optional( - v.object({ - userAgent: v.optional(v.string()), - deviceId: v.optional(v.string()), - ja3: v.optional(v.string()), - tlsVersion: v.optional(v.string()), - }) - ), - indicadores: v.optional( - v.array( - v.object({ - tipo: v.string(), - valor: v.string(), - confianca: v.optional(v.number()), - }) - ) - ), - metricas: v.optional( - v.object({ - pps: v.optional(v.number()), - bps: v.optional(v.number()), - rpm: v.optional(v.number()), - errosPorSegundo: v.optional(v.number()), - hostsAfetados: v.optional(v.number()), - }) - ), - correlacoes: v.optional(v.array(v.id("securityEvents"))), - referenciasExternas: v.optional(v.array(v.string())), - tags: v.optional(v.array(v.string())), - criadoPor: v.optional(v.id("usuarios")), - atualizadoEm: v.number(), - }) - .index("by_referencia", ["referencia"]) - .index("by_timestamp", ["timestamp"]) - .index("by_tipo", ["tipoAtaque", "timestamp"]) - .index("by_severidade", ["severidade", "timestamp"]) - .index("by_status", ["status", "timestamp"]), - - incidentActions: defineTable({ - eventoId: v.id("securityEvents"), - tipo: acaoIncidenteTipo, - origem: v.union(v.literal("automatico"), v.literal("manual")), - status: acaoIncidenteStatus, - executadoPor: v.optional(v.id("usuarios")), - detalhes: v.optional(v.string()), - resultado: v.optional(v.string()), - relacionadoA: v.optional(v.id("ipReputation")), - criadoEm: v.number(), - atualizadoEm: v.number(), - }) - .index("by_evento", ["eventoId", "status"]) - .index("by_tipo", ["tipo", "status"]), - - reportRequests: defineTable({ - solicitanteId: v.id("usuarios"), - filtros: v.object({ - dataInicio: v.number(), - dataFim: v.number(), - severidades: v.optional(v.array(severidadeSeguranca)), - tiposAtaque: v.optional(v.array(ataqueCiberneticoTipo)), - incluirIndicadores: v.optional(v.boolean()), - incluirMetricas: v.optional(v.boolean()), - incluirAcoes: v.optional(v.boolean()), - }), - status: reportStatus, - resultadoId: v.optional(v.id("_storage")), - observacoes: v.optional(v.string()), - criadoEm: v.number(), - atualizadoEm: v.number(), - concluidoEm: v.optional(v.number()), - erro: v.optional(v.string()), - }) - .index("by_status", ["status"]) - .index("by_solicitante", ["solicitanteId", "status"]) - .index("by_criado_em", ["criadoEm"]), - - rateLimitConfig: defineTable({ - nome: v.string(), - tipo: v.union( - v.literal("ip"), - v.literal("usuario"), - v.literal("endpoint"), - v.literal("global") - ), - identificador: v.optional(v.string()), - limite: v.number(), - janelaSegundos: v.number(), - estrategia: v.union( - v.literal("fixed_window"), - v.literal("sliding_window"), - v.literal("token_bucket") - ), - acaoExcedido: v.union( - v.literal("bloquear"), - v.literal("throttle"), - v.literal("alertar") - ), - bloqueioTemporarioSegundos: v.optional(v.number()), - ativo: v.boolean(), - prioridade: v.number(), - criadoPor: v.id("usuarios"), - atualizadoPor: v.optional(v.id("usuarios")), - criadoEm: v.number(), - atualizadoEm: v.number(), - notas: v.optional(v.string()), - tags: v.optional(v.array(v.string())) - }) - .index("by_tipo_identificador", ["tipo", "identificador"]) - .index("by_ativo", ["ativo"]) - .index("by_prioridade", ["prioridade"]) - , - alertConfigs: defineTable({ - nome: v.string(), - canais: v.object({ - email: v.boolean(), - chat: v.boolean(), - }), - emails: v.array(v.string()), - chatUsers: v.array(v.string()), - severidadeMin: severidadeSeguranca, - tiposAtaque: v.optional(v.array(ataqueCiberneticoTipo)), - reenvioMin: v.number(), - criadoPor: v.id("usuarios"), - criadoEm: v.number(), - atualizadoEm: v.number(), - }) - .index("by_criadoEm", ["criadoEm"]), - - // Sistema de Controle de Ponto - registrosPonto: defineTable({ - funcionarioId: v.id("funcionarios"), - tipo: v.union( - v.literal("entrada"), - v.literal("saida_almoco"), - v.literal("retorno_almoco"), - v.literal("saida") - ), - data: v.string(), // YYYY-MM-DD - hora: v.number(), - minuto: v.number(), - segundo: v.number(), - timestamp: v.number(), // Timestamp completo para ordenação - imagemId: v.optional(v.id("_storage")), - sincronizadoComServidor: v.boolean(), - toleranciaMinutos: v.number(), - dentroDoPrazo: v.boolean(), - - // Informações de Rede - ipAddress: v.optional(v.string()), - ipPublico: v.optional(v.string()), - ipLocal: v.optional(v.string()), - - // Informações do Navegador - userAgent: v.optional(v.string()), - browser: v.optional(v.string()), - browserVersion: v.optional(v.string()), - engine: v.optional(v.string()), - - // Informações do Sistema - sistemaOperacional: v.optional(v.string()), - osVersion: v.optional(v.string()), - arquitetura: v.optional(v.string()), - plataforma: v.optional(v.string()), - - // Informações de Localização - latitude: v.optional(v.number()), - longitude: v.optional(v.number()), - precisao: v.optional(v.number()), - altitude: v.optional(v.union(v.number(), v.null())), - altitudeAccuracy: v.optional(v.union(v.number(), v.null())), - heading: v.optional(v.union(v.number(), v.null())), - speed: v.optional(v.union(v.number(), v.null())), - confiabilidadeGPS: v.optional(v.number()), // 0-1 (frontend) - scoreConfiancaBackend: v.optional(v.number()), // 0-1 (backend) - suspeitaSpoofing: v.optional(v.boolean()), - motivoSuspeita: v.optional(v.string()), - avisosValidacao: v.optional(v.array(v.string())), // Array de avisos detalhados da validação - distanciaIPvsGPS: v.optional(v.number()), // Distância em metros entre IP geolocation e GPS - velocidadeUltimoRegistro: v.optional(v.number()), // Velocidade em km/h do último registro - distanciaUltimoRegistro: v.optional(v.number()), // Distância em metros do último registro - tempoDecorridoHoras: v.optional(v.number()), // Tempo em horas desde o último registro - // Informações de Geofencing - enderecoMarcacaoEsperado: v.optional(v.id("enderecosMarcacao")), // Endereço mais próximo esperado - distanciaEnderecoEsperado: v.optional(v.number()), // Distância em metros do endereço esperado - dentroRaioPermitido: v.optional(v.boolean()), // Se está dentro do raio permitido - enderecoMarcacaoUsado: v.optional(v.id("enderecosMarcacao")), // Qual endereço foi usado na validação - raioToleranciaUsado: v.optional(v.number()), // Raio usado na validação em metros - endereco: v.optional(v.string()), - cidade: v.optional(v.string()), - estado: v.optional(v.string()), - pais: v.optional(v.string()), - timezone: v.optional(v.string()), - - // Informações do Dispositivo - deviceType: v.optional(v.string()), - deviceModel: v.optional(v.string()), - screenResolution: v.optional(v.string()), - coresTela: v.optional(v.number()), - idioma: v.optional(v.string()), - - // Informações Adicionais - isMobile: v.optional(v.boolean()), - isTablet: v.optional(v.boolean()), - isDesktop: v.optional(v.boolean()), - connectionType: v.optional(v.string()), - memoryInfo: v.optional(v.string()), - - // Informações de Sensores (Acelerômetro e Giroscópio) - acelerometroX: v.optional(v.number()), - acelerometroY: v.optional(v.number()), - acelerometroZ: v.optional(v.number()), - movimentoDetectado: v.optional(v.boolean()), - magnitudeMovimento: v.optional(v.number()), - variacaoAcelerometro: v.optional(v.number()), - giroscopioAlpha: v.optional(v.number()), - giroscopioBeta: v.optional(v.number()), - giroscopioGamma: v.optional(v.number()), - sensorDisponivel: v.optional(v.boolean()), - permissaoSensorNegada: v.optional(v.boolean()), - - // Justificativa opcional para o registro - justificativa: v.optional(v.string()), - - // Campos para homologação - editadoPorGestor: v.optional(v.boolean()), - homologacaoId: v.optional(v.id("homologacoesPonto")), - - criadoEm: v.number(), - }) - .index("by_funcionario_data", ["funcionarioId", "data"]) - .index("by_data", ["data"]) - .index("by_dentro_prazo", ["dentroDoPrazo", "data"]) - .index("by_funcionario_timestamp", ["funcionarioId", "timestamp"]), - - // Endereços de Marcação - Locais permitidos para registro de ponto - enderecosMarcacao: defineTable({ - nome: v.string(), // Ex: "Sede Principal", "Home Office João Silva", "Cliente ABC" - descricao: v.optional(v.string()), // Descrição opcional - // Coordenadas (obrigatórias) - latitude: v.number(), - longitude: v.number(), - // Endereço físico (para exibição) - endereco: v.string(), // Ex: "Rua Exemplo, 123" - bairro: v.optional(v.string()), // Bairro do endereço - cep: v.optional(v.string()), - cidade: v.string(), - estado: v.string(), - pais: v.optional(v.string()), // Padrão: "Brasil" - // Configurações - raioMetros: v.number(), // Raio de tolerância em metros (ex: 100m, 500m, 1000m) - ativo: v.boolean(), - // Tipos de uso - tipo: v.union( - v.literal("sede"), // Sede principal (para todos) - v.literal("home_office"), // Home office específico - v.literal("deslocamento"), // Deslocamento temporário - v.literal("cliente") // Local de cliente - ), - // Metadados - criadoPor: v.id("usuarios"), - criadoEm: v.number(), - atualizadoPor: v.optional(v.id("usuarios")), - atualizadoEm: v.optional(v.number()), - }) - .index("by_ativo", ["ativo"]) - .index("by_tipo", ["tipo"]) - .index("by_cidade", ["cidade"]), - - // Associação Funcionário ↔ Endereço de Marcação - funcionarioEnderecosMarcacao: defineTable({ - funcionarioId: v.id("funcionarios"), - enderecoMarcacaoId: v.id("enderecosMarcacao"), - // Configurações específicas do funcionário - raioMetrosPersonalizado: v.optional(v.number()), // Pode ter raio diferente do padrão - // Período de validade (para deslocamentos temporários) - dataInicio: v.optional(v.string()), // YYYY-MM-DD - dataFim: v.optional(v.string()), // YYYY-MM-DD - // Status - ativo: v.boolean(), - // Metadados - criadoPor: v.id("usuarios"), - criadoEm: v.number(), - }) - .index("by_funcionario", ["funcionarioId"]) - .index("by_endereco", ["enderecoMarcacaoId"]) - .index("by_funcionario_ativo", ["funcionarioId", "ativo"]) - .index("by_endereco_ativo", ["enderecoMarcacaoId", "ativo"]), - - configuracaoPonto: defineTable({ - horarioEntrada: v.string(), // HH:mm - horarioSaidaAlmoco: v.string(), // HH:mm - horarioRetornoAlmoco: v.string(), // HH:mm - horarioSaida: v.string(), // HH:mm - toleranciaMinutos: v.number(), - // Nomes personalizados dos tipos de registro - nomeEntrada: v.optional(v.string()), // Padrão: "Entrada 1" - nomeSaidaAlmoco: v.optional(v.string()), // Padrão: "Saída 1" - nomeRetornoAlmoco: v.optional(v.string()), // Padrão: "Entrada 2" - nomeSaida: v.optional(v.string()), // Padrão: "Saída 2" - // Ajuste de fuso horário (GMT offset em horas) - gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC) - // Configurações de geofencing - validarLocalizacao: v.optional(v.boolean()), // Habilitar/desabilitar validação de localização - toleranciaDistanciaMetros: v.optional(v.number()), // Raio padrão global em metros - ativo: v.boolean(), - atualizadoPor: v.id("usuarios"), - atualizadoEm: v.number(), - }) - .index("by_ativo", ["ativo"]), - - configuracaoRelogio: defineTable({ - servidorNTP: v.optional(v.string()), - portaNTP: v.optional(v.number()), - usarServidorExterno: v.boolean(), - fallbackParaPC: v.boolean(), - ultimaSincronizacao: v.optional(v.number()), - offsetSegundos: v.optional(v.number()), - // Ajuste de fuso horário (GMT offset em horas) - gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC) - atualizadoPor: v.id("usuarios"), - atualizadoEm: v.number(), - }) - .index("by_ativo", ["usarServidorExterno"]), - - // Banco de Horas - Saldo diário de horas trabalhadas - bancoHoras: defineTable({ - funcionarioId: v.id("funcionarios"), - data: v.string(), // YYYY-MM-DD - cargaHorariaDiaria: v.number(), // Horas esperadas do dia (em minutos) - horasTrabalhadas: v.number(), // Horas realmente trabalhadas (em minutos) - saldoMinutos: v.number(), // Saldo do dia (positivo = horas extras, negativo = déficit) - registrosPontoIds: v.array(v.id("registrosPonto")), // IDs dos registros do dia - calculadoEm: v.number(), - }) - .index("by_funcionario_data", ["funcionarioId", "data"]) - .index("by_funcionario", ["funcionarioId"]) - .index("by_data", ["data"]), - - // Homologações de Ponto - Edições e ajustes realizados pelo gestor - homologacoesPonto: defineTable({ - registroId: v.optional(v.id("registrosPonto")), // ID do registro editado (se for edição) - funcionarioId: v.id("funcionarios"), - gestorId: v.id("usuarios"), - // Dados do registro original (se for edição) - horaAnterior: v.optional(v.number()), - minutoAnterior: v.optional(v.number()), - // Dados do registro novo (se for edição) - horaNova: v.optional(v.number()), - minutoNova: v.optional(v.number()), - // Motivo e observações - motivoId: v.optional(v.string()), // ID do motivo (referência a atestados/declarações) - motivoTipo: v.optional(v.string()), // Tipo do motivo (atestado, declaracao, etc) - motivoDescricao: v.optional(v.string()), // Descrição do motivo - observacoes: v.optional(v.string()), - // Tipo de ajuste (se for ajuste de banco de horas) - tipoAjuste: v.optional(v.union( - v.literal("compensar"), - v.literal("abonar"), - v.literal("descontar") - )), - // Período do ajuste (se for ajuste de banco de horas) - periodoDias: v.optional(v.number()), - periodoHoras: v.optional(v.number()), - periodoMinutos: v.optional(v.number()), - // Ajuste em minutos (calculado) - ajusteMinutos: v.optional(v.number()), - criadoEm: v.number(), - }) - .index("by_funcionario", ["funcionarioId"]) - .index("by_gestor", ["gestorId"]) - .index("by_registro", ["registroId"]) - .index("by_data", ["criadoEm"]), - - // Dispensas de Registro - Períodos onde funcionário está dispensado de registrar ponto - dispensasRegistro: defineTable({ - funcionarioId: v.id("funcionarios"), - gestorId: v.id("usuarios"), - dataInicio: v.string(), // YYYY-MM-DD - horaInicio: v.number(), - minutoInicio: v.number(), - dataFim: v.string(), // YYYY-MM-DD - horaFim: v.number(), - minutoFim: v.number(), - motivo: v.string(), - isento: v.boolean(), // Se true, não expira (casos excepcionais) - ativo: v.boolean(), - criadoEm: v.number(), - }) - .index("by_funcionario", ["funcionarioId"]) - .index("by_gestor", ["gestorId"]) - .index("by_ativo", ["ativo"]) - .index("by_data_inicio", ["dataInicio"]) - .index("by_data_fim", ["dataFim"]), + // Setores da organização + setores: defineTable({ + nome: v.string(), + sigla: v.string(), + criadoPor: v.id('usuarios'), + createdAt: v.number() + }) + .index('by_nome', ['nome']) + .index('by_sigla', ['sigla']), + + // Relação muitos-para-muitos entre funcionários e setores + funcionarioSetores: defineTable({ + funcionarioId: v.id('funcionarios'), + setorId: v.id('setores'), + createdAt: v.number() + }) + .index('by_funcionarioId', ['funcionarioId']) + .index('by_setorId', ['setorId']) + .index('by_funcionarioId_and_setorId', ['funcionarioId', 'setorId']), + + // Templates de fluxo + flowTemplates: defineTable({ + name: v.string(), + description: v.optional(v.string()), + status: flowTemplateStatus, + createdBy: v.id('usuarios'), + createdAt: v.number() + }) + .index('by_status', ['status']) + .index('by_createdBy', ['createdBy']), + + // Passos de template de fluxo + flowSteps: defineTable({ + flowTemplateId: v.id('flowTemplates'), + name: v.string(), + description: v.optional(v.string()), + position: v.number(), + expectedDuration: v.number(), // em dias + setorId: v.id('setores'), + defaultAssigneeId: v.optional(v.id('usuarios')), + requiredDocuments: v.optional(v.array(v.string())) + }) + .index('by_flowTemplateId', ['flowTemplateId']) + .index('by_flowTemplateId_and_position', ['flowTemplateId', 'position']), + + // Instâncias de fluxo + flowInstances: defineTable({ + flowTemplateId: v.id('flowTemplates'), + contratoId: v.optional(v.id('contratos')), + managerId: v.id('usuarios'), + status: flowInstanceStatus, + startedAt: v.number(), + finishedAt: v.optional(v.number()), + currentStepId: v.optional(v.id('flowInstanceSteps')) + }) + .index('by_flowTemplateId', ['flowTemplateId']) + .index('by_contratoId', ['contratoId']) + .index('by_managerId', ['managerId']) + .index('by_status', ['status']), + + // Passos de instância de fluxo + flowInstanceSteps: defineTable({ + flowInstanceId: v.id('flowInstances'), + flowStepId: v.id('flowSteps'), + setorId: v.id('setores'), + assignedToId: v.optional(v.id('usuarios')), + status: flowInstanceStepStatus, + startedAt: v.optional(v.number()), + finishedAt: v.optional(v.number()), + notes: v.optional(v.string()), + notesUpdatedBy: v.optional(v.id('usuarios')), + notesUpdatedAt: v.optional(v.number()), + dueDate: v.optional(v.number()) + }) + .index('by_flowInstanceId', ['flowInstanceId']) + .index('by_flowInstanceId_and_status', ['flowInstanceId', 'status']) + .index('by_setorId', ['setorId']) + .index('by_assignedToId', ['assignedToId']), + + // Documentos de instância de fluxo + flowInstanceDocuments: defineTable({ + flowInstanceStepId: v.id('flowInstanceSteps'), + uploadedById: v.id('usuarios'), + storageId: v.id('_storage'), + name: v.string(), + uploadedAt: v.number() + }) + .index('by_flowInstanceStepId', ['flowInstanceStepId']) + .index('by_uploadedById', ['uploadedById']), + + // Sub-etapas de fluxo (para templates e instâncias) + flowSubSteps: defineTable({ + flowStepId: v.optional(v.id('flowSteps')), // Para templates + flowInstanceStepId: v.optional(v.id('flowInstanceSteps')), // Para instâncias + name: v.string(), + description: v.optional(v.string()), + status: v.union( + v.literal('pending'), + v.literal('in_progress'), + v.literal('completed'), + v.literal('blocked') + ), + position: v.number(), + createdBy: v.id('usuarios'), + createdAt: v.number() + }) + .index('by_flowStepId', ['flowStepId']) + .index('by_flowInstanceStepId', ['flowInstanceStepId']), + + // Notas de steps e sub-etapas + flowStepNotes: defineTable({ + flowStepId: v.optional(v.id('flowSteps')), + flowInstanceStepId: v.optional(v.id('flowInstanceSteps')), + flowSubStepId: v.optional(v.id('flowSubSteps')), + texto: v.string(), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + arquivos: v.array(v.id('_storage')) + }) + .index('by_flowStepId', ['flowStepId']) + .index('by_flowInstanceStepId', ['flowInstanceStepId']) + .index('by_flowSubStepId', ['flowSubStepId']), + + contratos: defineTable({ + contratadaId: v.id('empresas'), + objeto: v.string(), + numeroNotaEmpenho: v.string(), + responsavelId: v.id('funcionarios'), + departamento: v.string(), + situacao: situacaoContrato, + numeroProcessoLicitatorio: v.string(), + modalidade: v.string(), + numeroContrato: v.string(), + anoContrato: v.number(), + dataInicioVigencia: v.string(), + dataFimVigencia: v.string(), + nomeFiscal: v.string(), + valorTotal: v.string(), + dataAditivoPrazo: v.optional(v.string()), + diasAvisoVencimento: v.number(), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoEm: v.optional(v.number()) + }) + .index('by_responsavel', ['responsavelId']) + .index('by_situacao', ['situacao']) + .index('by_vigencia_inicio', ['dataInicioVigencia']) + .index('by_vigencia_fim', ['dataFimVigencia']), + + todos: defineTable({ + text: v.string(), + completed: v.boolean() + }), + enderecos: defineTable({ + cep: v.string(), + logradouro: v.string(), + numero: v.string(), + complemento: v.optional(v.string()), + bairro: v.string(), + cidade: v.string(), + uf: v.string(), + criadoPor: v.optional(v.id('usuarios')), + atualizadoPor: v.optional(v.id('usuarios')) + }).index('by_cep', ['cep']), + empresas: defineTable({ + razao_social: v.string(), + nome_fantasia: v.optional(v.string()), + cnpj: v.string(), + telefone: v.string(), + email: v.string(), + descricao: v.optional(v.string()), + enderecoId: v.optional(v.id('enderecos')), + criadoPor: v.optional(v.id('usuarios')) + }) + .index('by_razao_social', ['razao_social']) + .index('by_cnpj', ['cnpj']), + contatosEmpresa: defineTable({ + empresaId: v.id('empresas'), + nome: v.string(), + funcao: v.string(), + email: v.string(), + telefone: v.string(), + adicionadoPor: v.optional(v.id('usuarios')), + descricao: v.optional(v.string()) + }) + .index('by_empresa', ['empresaId']) + .index('by_email', ['email']), + funcionarios: defineTable({ + // Campos obrigatórios existentes + nome: v.string(), + nascimento: v.string(), + rg: v.string(), + cpf: v.string(), + endereco: v.string(), + cep: v.string(), + cidade: v.string(), + uf: v.string(), + telefone: v.string(), + email: v.string(), + matricula: v.optional(v.string()), + admissaoData: v.optional(v.string()), + desligamentoData: v.optional(v.string()), + simboloId: v.id('simbolos'), + simboloTipo: simboloTipo, + gestorId: v.optional(v.id('usuarios')), + statusFerias: v.optional(v.union(v.literal('ativo'), v.literal('em_ferias'))), + + // Regime de trabalho (para cálculo correto de férias) + regimeTrabalho: v.optional( + v.union( + v.literal('clt'), // CLT - Consolidação das Leis do Trabalho + v.literal('estatutario_pe'), // Servidor Público Estadual de Pernambuco + v.literal('estatutario_federal'), // Servidor Público Federal + v.literal('estatutario_municipal') // Servidor Público Municipal + ) + ), + + // Dados Pessoais Adicionais (opcionais) + nomePai: v.optional(v.string()), + nomeMae: v.optional(v.string()), + naturalidade: v.optional(v.string()), + naturalidadeUF: v.optional(v.string()), + sexo: v.optional(v.union(v.literal('masculino'), v.literal('feminino'), v.literal('outro'))), + estadoCivil: v.optional( + v.union( + v.literal('solteiro'), + v.literal('casado'), + v.literal('divorciado'), + v.literal('viuvo'), + v.literal('uniao_estavel') + ) + ), + nacionalidade: v.optional(v.string()), + + // Documentos Pessoais + rgOrgaoExpedidor: v.optional(v.string()), + rgDataEmissao: v.optional(v.string()), + carteiraProfissionalNumero: v.optional(v.string()), + carteiraProfissionalSerie: v.optional(v.string()), + carteiraProfissionalDataEmissao: v.optional(v.string()), + reservistaNumero: v.optional(v.string()), + reservistaSerie: v.optional(v.string()), + tituloEleitorNumero: v.optional(v.string()), + tituloEleitorZona: v.optional(v.string()), + tituloEleitorSecao: v.optional(v.string()), + pisNumero: v.optional(v.string()), + + // Formação e Saúde + grauInstrucao: v.optional( + v.union( + v.literal('fundamental'), + v.literal('medio'), + v.literal('superior'), + v.literal('pos_graduacao'), + v.literal('mestrado'), + v.literal('doutorado') + ) + ), + formacao: v.optional(v.string()), + formacaoRegistro: v.optional(v.string()), + grupoSanguineo: v.optional( + v.union(v.literal('A'), v.literal('B'), v.literal('AB'), v.literal('O')) + ), + fatorRH: v.optional(v.union(v.literal('positivo'), v.literal('negativo'))), + + // Cargo e Vínculo + descricaoCargo: v.optional(v.string()), + nomeacaoPortaria: v.optional(v.string()), + nomeacaoData: v.optional(v.string()), + nomeacaoDOE: v.optional(v.string()), + pertenceOrgaoPublico: v.optional(v.boolean()), + orgaoOrigem: v.optional(v.string()), + aposentado: v.optional(v.union(v.literal('nao'), v.literal('funape_ipsep'), v.literal('inss'))), + + // Dados Bancários + contaBradescoNumero: v.optional(v.string()), + contaBradescoDV: v.optional(v.string()), + contaBradescoAgencia: v.optional(v.string()), + + // Documentos Anexos (Storage IDs) + certidaoAntecedentesPF: v.optional(v.id('_storage')), + certidaoAntecedentesJFPE: v.optional(v.id('_storage')), + certidaoAntecedentesSDS: v.optional(v.id('_storage')), + certidaoAntecedentesTJPE: v.optional(v.id('_storage')), + certidaoImprobidade: v.optional(v.id('_storage')), + rgFrente: v.optional(v.id('_storage')), + rgVerso: v.optional(v.id('_storage')), + cpfFrente: v.optional(v.id('_storage')), + cpfVerso: v.optional(v.id('_storage')), + situacaoCadastralCPF: v.optional(v.id('_storage')), + tituloEleitorFrente: v.optional(v.id('_storage')), + tituloEleitorVerso: v.optional(v.id('_storage')), + comprovanteVotacao: v.optional(v.id('_storage')), + carteiraProfissionalFrente: v.optional(v.id('_storage')), + carteiraProfissionalVerso: v.optional(v.id('_storage')), + comprovantePIS: v.optional(v.id('_storage')), + certidaoRegistroCivil: v.optional(v.id('_storage')), + certidaoNascimentoDependentes: v.optional(v.id('_storage')), + cpfDependentes: v.optional(v.id('_storage')), + reservistaDoc: v.optional(v.id('_storage')), + comprovanteEscolaridade: v.optional(v.id('_storage')), + comprovanteResidencia: v.optional(v.id('_storage')), + comprovanteContaBradesco: v.optional(v.id('_storage')), + + // Dependentes do funcionário (uploads opcionais) + dependentes: v.optional( + v.array( + v.object({ + parentesco: v.union( + v.literal('filho'), + v.literal('filha'), + v.literal('conjuge'), + v.literal('outro') + ), + nome: v.string(), + cpf: v.string(), + nascimento: v.string(), + documentoId: v.optional(v.id('_storage')), + // Benefícios/declarações por dependente + salarioFamilia: v.optional(v.boolean()), + impostoRenda: v.optional(v.boolean()) + }) + ) + ), + + // Declarações (Storage IDs) + declaracaoAcumulacaoCargo: v.optional(v.id('_storage')), + declaracaoDependentesIR: v.optional(v.id('_storage')), + declaracaoIdoneidade: v.optional(v.id('_storage')), + termoNepotismo: v.optional(v.id('_storage')), + termoOpcaoRemuneracao: v.optional(v.id('_storage')) + }) + .index('by_matricula', ['matricula']) + .index('by_nome', ['nome']) + .index('by_simboloId', ['simboloId']) + .index('by_simboloTipo', ['simboloTipo']) + .index('by_cpf', ['cpf']) + .index('by_rg', ['rg']) + .index('by_gestor', ['gestorId']), + + atestados: defineTable({ + funcionarioId: v.id('funcionarios'), + tipo: v.union(v.literal('atestado_medico'), v.literal('declaracao_comparecimento')), + dataInicio: v.string(), + dataFim: v.string(), + cid: v.optional(v.string()), // Apenas para atestado médico + observacoes: v.optional(v.string()), + documentoId: v.optional(v.id('_storage')), + criadoPor: v.id('usuarios'), + criadoEm: v.number() + }) + .index('by_funcionario', ['funcionarioId']) + .index('by_tipo', ['tipo']) + .index('by_data_inicio', ['dataInicio']) + .index('by_funcionario_and_tipo', ['funcionarioId', 'tipo']), + + licencas: defineTable({ + funcionarioId: v.id('funcionarios'), + tipo: v.union(v.literal('maternidade'), v.literal('paternidade')), + dataInicio: v.string(), + dataFim: v.string(), + documentoId: v.optional(v.id('_storage')), + observacoes: v.optional(v.string()), + licencaOriginalId: v.optional(v.id('licencas')), // Para prorrogações + ehProrrogacao: v.boolean(), + criadoPor: v.id('usuarios'), + criadoEm: v.number() + }) + .index('by_funcionario', ['funcionarioId']) + .index('by_tipo', ['tipo']) + .index('by_data_inicio', ['dataInicio']) + .index('by_licenca_original', ['licencaOriginalId']) + .index('by_funcionario_and_tipo', ['funcionarioId', 'tipo']), + + ferias: defineTable({ + funcionarioId: v.id('funcionarios'), + anoReferencia: v.number(), + dataInicio: v.string(), + dataFim: v.string(), + diasFerias: v.number(), + status: v.union( + v.literal('aguardando_aprovacao'), + v.literal('aprovado'), + v.literal('reprovado'), + v.literal('data_ajustada_aprovada'), + v.literal('EmFérias'), + v.literal('Cancelado_RH') + ), + gestorId: v.optional(v.id('usuarios')), + observacao: v.optional(v.string()), + motivoReprovacao: v.optional(v.string()), + dataAprovacao: v.optional(v.number()), + dataReprovacao: v.optional(v.number()), + diasAbono: v.number(), + historicoAlteracoes: v.optional( + v.array( + v.object({ + data: v.number(), + usuarioId: v.id('usuarios'), + acao: v.string() + }) + ) + ) + }) + .index('by_funcionario', ['funcionarioId']) + .index('by_funcionario_and_ano', ['funcionarioId', 'anoReferencia']) + .index('by_funcionario_and_status', ['funcionarioId', 'status']) + .index('by_status', ['status']) + .index('by_ano', ['anoReferencia']), + + notificacoesFerias: defineTable({ + destinatarioId: v.id('usuarios'), + feriasId: v.id('ferias'), + tipo: v.union( + v.literal('nova_solicitacao'), + v.literal('aprovado'), + v.literal('reprovado'), + v.literal('data_ajustada') + ), + lida: v.boolean(), + mensagem: v.string() + }) + .index('by_destinatario', ['destinatarioId']) + .index('by_destinatario_and_lida', ['destinatarioId', 'lida']), + + // Solicitações de Ausências + solicitacoesAusencias: defineTable({ + funcionarioId: v.id('funcionarios'), + dataInicio: v.string(), + dataFim: v.string(), + motivo: v.string(), + status: v.union( + v.literal('aguardando_aprovacao'), + v.literal('aprovado'), + v.literal('reprovado') + ), + gestorId: v.optional(v.id('usuarios')), + dataAprovacao: v.optional(v.number()), + dataReprovacao: v.optional(v.number()), + motivoReprovacao: v.optional(v.string()), + observacao: v.optional(v.string()), + criadoEm: v.number() + }) + .index('by_funcionario', ['funcionarioId']) + .index('by_status', ['status']) + .index('by_funcionario_and_status', ['funcionarioId', 'status']), + + notificacoesAusencias: defineTable({ + destinatarioId: v.id('usuarios'), + solicitacaoAusenciaId: v.id('solicitacoesAusencias'), + tipo: v.union(v.literal('nova_solicitacao'), v.literal('aprovado'), v.literal('reprovado')), + lida: v.boolean(), + mensagem: v.string() + }) + .index('by_destinatario', ['destinatarioId']) + .index('by_destinatario_and_lida', ['destinatarioId', 'lida']), + + times: defineTable({ + nome: v.string(), + descricao: v.optional(v.string()), + gestorId: v.id('usuarios'), + gestorSuperiorId: v.optional(v.id('usuarios')), + ativo: v.boolean(), + cor: v.optional(v.string()) // Cor para identificação visual + }) + .index('by_gestor', ['gestorId']) + .index('by_gestor_superior', ['gestorSuperiorId']), + + timesMembros: defineTable({ + timeId: v.id('times'), + funcionarioId: v.id('funcionarios'), + dataEntrada: v.number(), + dataSaida: v.optional(v.number()), + ativo: v.boolean() + }) + .index('by_time', ['timeId']) + .index('by_funcionario', ['funcionarioId']) + .index('by_time_and_ativo', ['timeId', 'ativo']), + + cursos: defineTable({ + funcionarioId: v.id('funcionarios'), + descricao: v.string(), + data: v.string(), + certificadoId: v.optional(v.id('_storage')) + }).index('by_funcionario', ['funcionarioId']), + + simbolos: defineTable({ + nome: v.string(), + tipo: simboloTipo, + descricao: v.string(), + vencValor: v.string(), + repValor: v.string(), + valor: v.string() + }), + + // Sistema de Autenticação e Controle de Acesso + usuarios: defineTable({ + authId: v.string(), + nome: v.string(), + email: v.string(), + funcionarioId: v.optional(v.id('funcionarios')), + roleId: v.id('roles'), + ativo: v.boolean(), + primeiroAcesso: v.boolean(), + ultimoAcesso: v.optional(v.number()), + criadoEm: v.number(), + atualizadoEm: v.number(), + + // Controle de Bloqueio e Segurança + bloqueado: v.optional(v.boolean()), + motivoBloqueio: v.optional(v.string()), + dataBloqueio: v.optional(v.number()), + tentativasLogin: v.optional(v.number()), // contador de tentativas falhas + ultimaTentativaLogin: v.optional(v.number()), // timestamp da última tentativa + + // Campos de Chat e Perfil + + fotoPerfil: v.optional(v.id('_storage')), + avatar: v.optional(v.string()), // URL do avatar gerado (ex: DiceBear) + setor: v.optional(v.string()), + statusMensagem: v.optional(v.string()), // max 100 chars + statusPresenca: v.optional( + v.union( + v.literal('online'), + v.literal('offline'), + v.literal('ausente'), + v.literal('externo'), + v.literal('em_reuniao') + ) + ), + ultimaAtividade: v.optional(v.number()), // timestamp + notificacoesAtivadas: v.optional(v.boolean()), + somNotificacao: v.optional(v.boolean()), + temaPreferido: v.optional(v.string()) // tema de aparência escolhido pelo usuário + }) + .index('by_email', ['email']) + .index('by_role', ['roleId']) + .index('by_ativo', ['ativo']) + .index('by_status_presenca', ['statusPresenca']) + .index('by_bloqueado', ['bloqueado']) + .index('by_funcionarioId', ['funcionarioId']) + .index('authId', ['authId']), + + roles: defineTable({ + nome: v.string(), // "admin", "ti_master", "ti_usuario", "usuario_avancado", "usuario" + descricao: v.string(), + nivel: v.number(), // 0 = admin, 1 = ti_master, 2 = ti_usuario, 3+ = customizado + setor: v.optional(v.string()), // "ti", "rh", "financeiro", etc. + customizado: v.optional(v.boolean()), // se é um perfil customizado criado por TI_MASTER + criadoPor: v.optional(v.id('usuarios')), // usuário TI_MASTER que criou este perfil + editavel: v.optional(v.boolean()) // se pode ser editado (false para roles fixas) + }) + .index('by_nome', ['nome']) + .index('by_nivel', ['nivel']) + .index('by_setor', ['setor']) + .index('by_customizado', ['customizado']), + + permissoes: defineTable({ + nome: v.string(), // "funcionarios.criar", "simbolos.editar", etc. + descricao: v.string(), + recurso: v.string(), // "funcionarios", "simbolos", "usuarios", etc. + acao: v.string() // "criar", "ler", "editar", "excluir" + }) + .index('by_recurso', ['recurso']) + .index('by_recurso_e_acao', ['recurso', 'acao']) + .index('by_nome', ['nome']), + + rolePermissoes: defineTable({ + roleId: v.id('roles'), + permissaoId: v.id('permissoes') + }) + .index('by_role', ['roleId']) + .index('by_permissao', ['permissaoId']), + + sessoes: defineTable({ + usuarioId: v.id('usuarios'), + token: v.string(), + ipAddress: v.optional(v.string()), + userAgent: v.optional(v.string()), + criadoEm: v.number(), + expiraEm: v.number(), + ativo: v.boolean() + }) + .index('by_usuario', ['usuarioId']) + .index('by_token', ['token']) + .index('by_ativo', ['ativo']) + .index('by_expiracao', ['expiraEm']), + + logsAcesso: defineTable({ + usuarioId: v.id('usuarios'), + tipo: v.union( + v.literal('login'), + v.literal('logout'), + v.literal('acesso_negado'), + v.literal('senha_alterada'), + v.literal('sessao_expirada') + ), + ipAddress: v.optional(v.string()), + userAgent: v.optional(v.string()), + detalhes: v.optional(v.string()), + timestamp: v.number() + }) + .index('by_usuario', ['usuarioId']) + .index('by_tipo', ['tipo']) + .index('by_timestamp', ['timestamp']), + + // Logs de Login Detalhados + logsLogin: defineTable({ + usuarioId: v.optional(v.id('usuarios')), // pode ser null se falha antes de identificar usuário + matriculaOuEmail: v.string(), // tentativa de login + sucesso: v.boolean(), + motivoFalha: v.optional(v.string()), // "senha_incorreta", "usuario_bloqueado", "usuario_inexistente" + ipAddress: v.optional(v.string()), + userAgent: v.optional(v.string()), + device: v.optional(v.string()), + browser: v.optional(v.string()), + sistema: v.optional(v.string()), + timestamp: v.number() + }) + .index('by_usuario', ['usuarioId']) + .index('by_sucesso', ['sucesso']) + .index('by_timestamp', ['timestamp']) + .index('by_ip', ['ipAddress']), + + // Logs de Atividades + logsAtividades: defineTable({ + usuarioId: v.id('usuarios'), + acao: v.string(), // "criar", "editar", "excluir", "bloquear", "desbloquear", etc. + recurso: v.string(), // "funcionarios", "simbolos", "usuarios", "perfis", etc. + recursoId: v.optional(v.string()), // ID do recurso afetado + detalhes: v.optional(v.string()), // JSON com detalhes da ação + timestamp: v.number() + }) + .index('by_usuario', ['usuarioId']) + .index('by_acao', ['acao']) + .index('by_recurso', ['recurso']) + .index('by_timestamp', ['timestamp']) + .index('by_recurso_id', ['recurso', 'recursoId']), + + // Histórico de Bloqueios + bloqueiosUsuarios: defineTable({ + usuarioId: v.id('usuarios'), + motivo: v.string(), + bloqueadoPor: v.id('usuarios'), // ID do TI_MASTER que bloqueou + dataInicio: v.number(), + dataFim: v.optional(v.number()), // quando foi desbloqueado + desbloqueadoPor: v.optional(v.id('usuarios')), + ativo: v.boolean() // se é o bloqueio atual ativo + }) + .index('by_usuario', ['usuarioId']) + .index('by_bloqueado_por', ['bloqueadoPor']) + .index('by_ativo', ['ativo']) + .index('by_data_inicio', ['dataInicio']), + + // Perfis Customizados + + // Templates de Mensagens + templatesMensagens: defineTable({ + codigo: v.string(), // "USUARIO_BLOQUEADO", "SENHA_RESETADA", etc. + nome: v.string(), + tipo: v.union( + v.literal('sistema'), // predefinido, não editável + v.literal('customizado') // criado por TI_MASTER + ), + titulo: v.string(), + corpo: v.string(), // pode ter variáveis {{variavel}} + variaveis: v.optional(v.array(v.string())), // ["motivo", "senha", etc.] + criadoPor: v.optional(v.id('usuarios')), + criadoEm: v.number() + }) + .index('by_codigo', ['codigo']) + .index('by_tipo', ['tipo']) + .index('by_criado_por', ['criadoPor']), + + // Configuração de Email/SMTP + configuracaoEmail: defineTable({ + servidor: v.string(), // smtp.gmail.com + porta: v.number(), // 587, 465, etc. + usuario: v.string(), + senhaHash: v.string(), // senha criptografada reversível (AES-GCM) - necessário para descriptografar e usar no SMTP + emailRemetente: v.string(), + nomeRemetente: v.string(), + usarSSL: v.boolean(), + usarTLS: v.boolean(), + ativo: v.boolean(), + testadoEm: v.optional(v.number()), + configuradoPor: v.id('usuarios'), + atualizadoEm: v.number() + }).index('by_ativo', ['ativo']), + + // Configuração de Jitsi Meet + configuracaoJitsi: defineTable({ + domain: v.string(), // Domínio do servidor Jitsi (ex: "localhost:8443" ou "meet.example.com") + appId: v.string(), // ID da aplicação Jitsi + roomPrefix: v.string(), // Prefixo para nomes de salas + useHttps: v.boolean(), // Usar HTTPS + acceptSelfSignedCert: v.optional(v.boolean()), // Aceitar certificados autoassinados (útil para desenvolvimento) + // Configurações SSH/Docker para configuração automática do servidor + sshHost: v.optional(v.string()), // Host SSH para acesso ao servidor Docker (ex: "192.168.1.100" ou "servidor.local") + sshPort: v.optional(v.number()), // Porta SSH (padrão: 22) + sshUsername: v.optional(v.string()), // Usuário SSH + sshPasswordHash: v.optional(v.string()), // Hash da senha SSH (criptografada) + sshKeyPath: v.optional(v.string()), // Caminho para chave SSH (alternativa à senha) + dockerComposePath: v.optional(v.string()), // Caminho do docker-compose.yml (ex: "/home/user/jitsi-docker") + jitsiConfigPath: v.optional(v.string()), // Caminho base das configurações Jitsi (ex: "~/.jitsi-meet-cfg") + ativo: v.boolean(), // Configuração ativa + testadoEm: v.optional(v.number()), // Timestamp do último teste de conexão + configuradoEm: v.optional(v.number()), // Timestamp da última configuração do servidor Docker + configuradoNoServidor: v.optional(v.boolean()), // Indica se a configuração foi aplicada no servidor + configuradoNoServidorEm: v.optional(v.number()), // Timestamp de quando foi configurado no servidor + configuradoPor: v.id('usuarios'), // Usuário que configurou + atualizadoEm: v.number() // Timestamp de atualização + }).index('by_ativo', ['ativo']), + + // Fila de Emails + notificacoesEmail: defineTable({ + destinatario: v.string(), // email + destinatarioId: v.optional(v.id('usuarios')), + assunto: v.string(), + corpo: v.string(), // HTML ou texto + templateId: v.optional(v.id('templatesMensagens')), + status: v.union( + v.literal('pendente'), + v.literal('enviando'), + v.literal('enviado'), + v.literal('falha') + ), + tentativas: v.number(), + ultimaTentativa: v.optional(v.number()), + erroDetalhes: v.optional(v.string()), + enviadoPor: v.id('usuarios'), + criadoEm: v.number(), + enviadoEm: v.optional(v.number()), + agendadaPara: v.optional(v.number()) // timestamp para agendamento + }) + .index('by_status', ['status']) + .index('by_destinatario', ['destinatarioId']) + .index('by_enviado_por', ['enviadoPor']) + .index('by_criado_em', ['criadoEm']) + .index('by_agendamento', ['agendadaPara']), + + configuracaoAcesso: defineTable({ + chave: v.string(), // "sessao_duracao", "max_tentativas_login", etc. + valor: v.string(), + descricao: v.string() + }).index('by_chave', ['chave']), + + // Rate Limiting de Emails + rateLimitEmails: defineTable({ + remetenteId: v.id('usuarios'), + timestamp: v.number(), + contador: v.number(), // quantidade de emails enviados neste período + periodo: v.union( + v.literal('minuto'), // último minuto + v.literal('hora') // última hora + ) + }) + .index('by_remetente_periodo', ['remetenteId', 'periodo', 'timestamp']) + .index('by_timestamp', ['timestamp']), + + // Sistema de Chat + conversas: defineTable({ + tipo: v.union(v.literal('individual'), v.literal('grupo'), v.literal('sala_reuniao')), + nome: v.optional(v.string()), // nome do grupo/sala + + participantes: v.array(v.id('usuarios')), // IDs dos participantes + administradores: v.optional(v.array(v.id('usuarios'))), // IDs dos administradores (apenas para sala_reuniao) + ultimaMensagem: v.optional(v.string()), + ultimaMensagemTimestamp: v.optional(v.number()), + ultimaMensagemRemetenteId: v.optional(v.id('usuarios')), // ID do remetente da última mensagem + criadoPor: v.id('usuarios'), + criadoEm: v.number() + }) + .index('by_criado_por', ['criadoPor']) + .index('by_tipo', ['tipo']) + .index('by_ultima_mensagem', ['ultimaMensagemTimestamp']), + + mensagens: defineTable({ + conversaId: v.id('conversas'), + remetenteId: v.id('usuarios'), + tipo: v.union(v.literal('texto'), v.literal('arquivo'), v.literal('imagem')), + conteudo: v.string(), // texto ou nome do arquivo + conteudoBusca: v.optional(v.string()), // versão normalizada para busca + arquivoId: v.optional(v.id('_storage')), + arquivoNome: v.optional(v.string()), + arquivoTamanho: v.optional(v.number()), + arquivoTipo: v.optional(v.string()), + linkPreview: v.optional( + v.object({ + url: v.string(), + titulo: v.optional(v.string()), + descricao: v.optional(v.string()), + imagem: v.optional(v.string()), + site: v.optional(v.string()) + }) + ), + reagiuPor: v.optional( + v.array( + v.object({ + usuarioId: v.id('usuarios'), + emoji: v.string() + }) + ) + ), + mencoes: v.optional(v.array(v.id('usuarios'))), + respostaPara: v.optional(v.id('mensagens')), // ID da mensagem que está respondendo + agendadaPara: v.optional(v.number()), // timestamp + enviadaEm: v.number(), + editadaEm: v.optional(v.number()), + deletada: v.optional(v.boolean()), + lidaPor: v.optional(v.array(v.id('usuarios'))) // IDs dos usuários que leram a mensagem + }) + .index('by_conversa', ['conversaId', 'enviadaEm']) + .index('by_remetente', ['remetenteId']) + .index('by_agendamento', ['agendadaPara']) + .index('by_resposta', ['respostaPara']), + + leituras: defineTable({ + conversaId: v.id('conversas'), + usuarioId: v.id('usuarios'), + ultimaMensagemLida: v.id('mensagens'), + lidaEm: v.number() + }) + .index('by_conversa_usuario', ['conversaId', 'usuarioId']) + .index('by_usuario', ['usuarioId']), + + // Sistema de Chamadas de Áudio/Vídeo + chamadas: defineTable({ + conversaId: v.id('conversas'), + tipo: v.union(v.literal('audio'), v.literal('video')), + roomName: v.string(), // Nome único da sala Jitsi + criadoPor: v.id('usuarios'), // Anfitrião/criador + participantes: v.array(v.id('usuarios')), + status: v.union( + v.literal('aguardando'), + v.literal('em_andamento'), + v.literal('finalizada'), + v.literal('cancelada') + ), + iniciadaEm: v.optional(v.number()), + finalizadaEm: v.optional(v.number()), + duracaoSegundos: v.optional(v.number()), + gravando: v.boolean(), + gravacaoIniciadaPor: v.optional(v.id('usuarios')), + gravacaoIniciadaEm: v.optional(v.number()), + gravacaoFinalizadaEm: v.optional(v.number()), + configuracoes: v.optional( + v.object({ + audioHabilitado: v.boolean(), + videoHabilitado: v.boolean(), + participantesConfig: v.optional( + v.array( + v.object({ + usuarioId: v.id('usuarios'), + audioHabilitado: v.boolean(), + videoHabilitado: v.boolean(), + forcadoPeloAnfitriao: v.optional(v.boolean()) // Se foi forçado pelo anfitrião + }) + ) + ) + }) + ), + criadoEm: v.number() + }) + .index('by_conversa', ['conversaId', 'status']) + .index('by_criado_por', ['criadoPor']) + .index('by_status', ['status']) + .index('by_room_name', ['roomName']), + + notificacoes: defineTable({ + usuarioId: v.id('usuarios'), + tipo: v.union( + v.literal('nova_mensagem'), + v.literal('mencao'), + v.literal('grupo_criado'), + v.literal('adicionado_grupo'), + v.literal('alerta_seguranca'), + v.literal('etapa_fluxo_concluida') + ), + conversaId: v.optional(v.id('conversas')), + mensagemId: v.optional(v.id('mensagens')), + remetenteId: v.optional(v.id('usuarios')), + titulo: v.string(), + descricao: v.string(), + lida: v.boolean(), + criadaEm: v.number() + }) + .index('by_usuario', ['usuarioId', 'lida', 'criadaEm']) + .index('by_usuario_lida', ['usuarioId', 'lida']), + + digitando: defineTable({ + conversaId: v.id('conversas'), + usuarioId: v.id('usuarios'), + iniciouEm: v.number() + }) + .index('by_conversa', ['conversaId', 'iniciouEm']) + .index('by_usuario', ['usuarioId']), + + // Push Notifications + pushSubscriptions: defineTable({ + usuarioId: v.id('usuarios'), + endpoint: v.string(), // URL do serviço de push + keys: v.object({ + p256dh: v.string(), // Chave pública + auth: v.string() // Chave de autenticação + }), + userAgent: v.optional(v.string()), + criadoEm: v.number(), + ultimaAtividade: v.number(), + ativo: v.boolean() + }) + .index('by_usuario', ['usuarioId', 'ativo']) + .index('by_endpoint', ['endpoint']), + + // Preferências de Notificação por Conversa + preferenciasNotificacaoConversa: defineTable({ + usuarioId: v.id('usuarios'), + conversaId: v.id('conversas'), + pushAtivado: v.boolean(), // Receber push notifications + emailAtivado: v.boolean(), // Receber emails quando offline + somAtivado: v.boolean(), // Tocar som + silenciado: v.boolean(), // Silenciar completamente + apenasMencoes: v.boolean(), // Notificar apenas quando mencionado + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_usuario_conversa', ['usuarioId', 'conversaId']) + .index('by_conversa', ['conversaId']), + + // Tabelas de Monitoramento do Sistema + systemMetrics: defineTable({ + timestamp: v.number(), + // Métricas de Sistema + cpuUsage: v.optional(v.number()), + memoryUsage: v.optional(v.number()), + networkLatency: v.optional(v.number()), + storageUsed: v.optional(v.number()), + // Métricas de Aplicação + usuariosOnline: v.optional(v.number()), + mensagensPorMinuto: v.optional(v.number()), + tempoRespostaMedio: v.optional(v.number()), + errosCount: v.optional(v.number()) + }).index('by_timestamp', ['timestamp']), + + alertConfigurations: defineTable({ + metricName: v.string(), + threshold: v.number(), + operator: v.union( + v.literal('>'), + v.literal('<'), + v.literal('>='), + v.literal('<='), + v.literal('==') + ), + enabled: v.boolean(), + notifyByEmail: v.boolean(), + notifyByChat: v.boolean(), + createdBy: v.id('usuarios'), + lastModified: v.number() + }).index('by_enabled', ['enabled']), + + alertHistory: defineTable({ + configId: v.id('alertConfigurations'), + metricName: v.string(), + metricValue: v.number(), + threshold: v.number(), + timestamp: v.number(), + status: v.union(v.literal('triggered'), v.literal('resolved')), + notificationsSent: v.object({ + email: v.boolean(), + chat: v.boolean() + }) + }) + .index('by_timestamp', ['timestamp']) + .index('by_status', ['status']) + .index('by_config', ['configId', 'timestamp']), + + tickets: defineTable({ + numero: v.string(), + titulo: v.string(), + descricao: v.string(), + tipo: v.union( + v.literal('reclamacao'), + v.literal('elogio'), + v.literal('sugestao'), + v.literal('chamado') + ), + categoria: v.optional(v.string()), + status: v.union( + v.literal('aberto'), + v.literal('em_andamento'), + v.literal('aguardando_usuario'), + v.literal('resolvido'), + v.literal('encerrado'), + v.literal('cancelado') + ), + prioridade: v.union( + v.literal('baixa'), + v.literal('media'), + v.literal('alta'), + v.literal('critica') + ), + solicitanteId: v.id('usuarios'), + solicitanteNome: v.string(), + solicitanteEmail: v.string(), + responsavelId: v.optional(v.id('usuarios')), + setorResponsavel: v.optional(v.string()), + slaConfigId: v.optional(v.id('slaConfigs')), + conversaId: v.optional(v.id('conversas')), + prazoResposta: v.optional(v.number()), + prazoConclusao: v.optional(v.number()), + prazoEncerramento: v.optional(v.number()), + timeline: v.optional( + v.array( + v.object({ + etapa: v.string(), + status: v.union( + v.literal('pendente'), + v.literal('em_andamento'), + v.literal('concluido'), + v.literal('vencido') + ), + prazo: v.optional(v.number()), + concluidoEm: v.optional(v.number()), + observacao: v.optional(v.string()) + }) + ) + ), + alertasEmitidos: v.optional( + v.array( + v.object({ + tipo: v.union(v.literal('resposta'), v.literal('conclusao'), v.literal('encerramento')), + emitidoEm: v.number() + }) + ) + ), + anexos: v.optional( + v.array( + v.object({ + arquivoId: v.id('_storage'), + nome: v.optional(v.string()), + tipo: v.optional(v.string()), + tamanho: v.optional(v.number()) + }) + ) + ), + tags: v.optional(v.array(v.string())), + canalOrigem: v.optional(v.string()), + ultimaInteracaoEm: v.number(), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_numero', ['numero']) + .index('by_status', ['status']) + .index('by_solicitante', ['solicitanteId', 'status']) + .index('by_responsavel', ['responsavelId', 'status']) + .index('by_setor', ['setorResponsavel', 'status']), + + ticketInteractions: defineTable({ + ticketId: v.id('tickets'), + autorId: v.optional(v.id('usuarios')), + origem: v.union(v.literal('usuario'), v.literal('ti'), v.literal('sistema')), + tipo: v.union( + v.literal('mensagem'), + v.literal('status'), + v.literal('anexo'), + v.literal('alerta') + ), + conteudo: v.string(), + anexos: v.optional( + v.array( + v.object({ + arquivoId: v.id('_storage'), + nome: v.optional(v.string()), + tipo: v.optional(v.string()), + tamanho: v.optional(v.number()) + }) + ) + ), + statusAnterior: v.optional( + v.union( + v.literal('aberto'), + v.literal('em_andamento'), + v.literal('aguardando_usuario'), + v.literal('resolvido'), + v.literal('encerrado'), + v.literal('cancelado') + ) + ), + statusNovo: v.optional( + v.union( + v.literal('aberto'), + v.literal('em_andamento'), + v.literal('aguardando_usuario'), + v.literal('resolvido'), + v.literal('encerrado'), + v.literal('cancelado') + ) + ), + visibilidade: v.union(v.literal('publico'), v.literal('interno')), + criadoEm: v.number() + }) + .index('by_ticket', ['ticketId']) + .index('by_ticket_type', ['ticketId', 'tipo']) + .index('by_autor', ['autorId']), + + slaConfigs: defineTable({ + nome: v.string(), + descricao: v.optional(v.string()), + prioridade: v.optional( + v.union(v.literal('baixa'), v.literal('media'), v.literal('alta'), v.literal('critica')) + ), + tempoRespostaHoras: v.number(), + tempoConclusaoHoras: v.number(), + tempoEncerramentoHoras: v.optional(v.number()), + alertaAntecedenciaHoras: v.number(), + ativo: v.boolean(), + criadoPor: v.id('usuarios'), + atualizadoPor: v.optional(v.id('usuarios')), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_ativo', ['ativo']) + .index('by_prioridade', ['prioridade', 'ativo']) + .index('by_nome', ['nome']), + + ticketAssignments: defineTable({ + ticketId: v.id('tickets'), + responsavelId: v.id('usuarios'), + atribuidoPor: v.id('usuarios'), + motivo: v.optional(v.string()), + ativo: v.boolean(), + criadoEm: v.number(), + encerradoEm: v.optional(v.number()) + }) + .index('by_ticket', ['ticketId', 'ativo']) + .index('by_responsavel', ['responsavelId', 'ativo']), + + // Sistema de Segurança Cibernética + networkSensors: defineTable({ + nome: v.string(), + tipo: sensorSegurancaTipo, + status: sensorSegurancaStatus, + escopo: v.optional(v.string()), + ipMonitorado: v.optional(v.string()), + hostname: v.optional(v.string()), + regioes: v.optional(v.array(v.string())), + portasMonitoradas: v.optional(v.array(v.number())), + protocolos: v.optional(v.array(v.string())), + capacidades: v.optional(v.array(v.string())), + ultimaSincronizacao: v.number(), + ultimoHeartbeat: v.optional(v.number()), + latenciaMs: v.optional(v.number()), + errosConsecutivos: v.optional(v.number()), + agenteVersao: v.optional(v.string()), + notas: v.optional(v.string()) + }) + .index('by_tipo', ['tipo']) + .index('by_status', ['status']) + .index('by_hostname', ['hostname']), + + ipReputation: defineTable({ + indicador: v.string(), + categoria: v.union( + v.literal('ip'), + v.literal('dominio'), + v.literal('hash'), + v.literal('email') + ), + reputacao: v.number(), // -100 (malicioso) até 100 (confiável) + severidadeMax: severidadeSeguranca, + whitelist: v.boolean(), + blacklist: v.boolean(), + ocorrencias: v.number(), + primeiroRegistro: v.number(), + ultimoRegistro: v.number(), + bloqueadoAte: v.optional(v.number()), + origem: v.optional(v.string()), + comentarios: v.optional(v.string()), + classificacoes: v.optional(v.array(v.string())), + ultimaAcaoId: v.optional(v.id('incidentActions')) + }) + .index('by_indicador', ['indicador']) + .index('by_reputacao', ['reputacao']) + .index('by_blacklist', ['blacklist']) + .index('by_whitelist', ['whitelist']), + + portRules: defineTable({ + porta: v.number(), + protocolo: v.union( + v.literal('tcp'), + v.literal('udp'), + v.literal('icmp'), + v.literal('quic'), + v.literal('any') + ), + acao: v.union( + v.literal('permitir'), + v.literal('bloquear'), + v.literal('monitorar'), + v.literal('rate_limit') + ), + temporario: v.boolean(), + severidadeMin: severidadeSeguranca, + duracaoSegundos: v.optional(v.number()), + expiraEm: v.optional(v.number()), + criadoPor: v.id('usuarios'), + atualizadoPor: v.optional(v.id('usuarios')), + criadoEm: v.number(), + atualizadoEm: v.number(), + notas: v.optional(v.string()), + tags: v.optional(v.array(v.string())), + listaReferencia: v.optional(v.id('ipReputation')) + }) + .index('by_porta_protocolo', ['porta', 'protocolo']) + .index('by_acao', ['acao']) + .index('by_expiracao', ['expiraEm']), + + threatIntelFeeds: defineTable({ + nomeFonte: v.string(), + tipo: threatIntelTipo, + formato: threatIntelFormato, + url: v.optional(v.string()), + ativo: v.boolean(), + prioridade: v.union( + v.literal('baixa'), + v.literal('media'), + v.literal('alta'), + v.literal('critica') + ), + ultimaSincronizacao: v.optional(v.number()), + entradasProcessadas: v.optional(v.number()), + errosConsecutivos: v.optional(v.number()), + autenticacaoNecessaria: v.optional(v.boolean()), + configuracao: v.optional( + v.object({ + tokenId: v.optional(v.id('_storage')), + escopo: v.optional(v.string()) + }) + ), + criadoPor: v.id('usuarios'), + atualizadoPor: v.optional(v.id('usuarios')), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_tipo', ['tipo']) + .index('by_ativo', ['ativo']) + .index('by_prioridade', ['prioridade']), + + securityEvents: defineTable({ + referencia: v.string(), + timestamp: v.number(), + tipoAtaque: ataqueCiberneticoTipo, + severidade: severidadeSeguranca, + status: statusEventoSeguranca, + descricao: v.string(), + origemIp: v.optional(v.string()), + origemRegiao: v.optional(v.string()), + origemAsn: v.optional(v.string()), + destinoIp: v.optional(v.string()), + destinoPorta: v.optional(v.number()), + protocolo: v.optional(v.string()), + transporte: v.optional(v.string()), + sensorId: v.optional(v.id('networkSensors')), + detectadoPor: v.optional(v.string()), + mitreTechnique: v.optional(v.string()), + geolocalizacao: v.optional( + v.object({ + pais: v.optional(v.string()), + regiao: v.optional(v.string()), + cidade: v.optional(v.string()), + latitude: v.optional(v.number()), + longitude: v.optional(v.number()) + }) + ), + fingerprint: v.optional( + v.object({ + userAgent: v.optional(v.string()), + deviceId: v.optional(v.string()), + ja3: v.optional(v.string()), + tlsVersion: v.optional(v.string()) + }) + ), + indicadores: v.optional( + v.array( + v.object({ + tipo: v.string(), + valor: v.string(), + confianca: v.optional(v.number()) + }) + ) + ), + metricas: v.optional( + v.object({ + pps: v.optional(v.number()), + bps: v.optional(v.number()), + rpm: v.optional(v.number()), + errosPorSegundo: v.optional(v.number()), + hostsAfetados: v.optional(v.number()) + }) + ), + correlacoes: v.optional(v.array(v.id('securityEvents'))), + referenciasExternas: v.optional(v.array(v.string())), + tags: v.optional(v.array(v.string())), + criadoPor: v.optional(v.id('usuarios')), + atualizadoEm: v.number() + }) + .index('by_referencia', ['referencia']) + .index('by_timestamp', ['timestamp']) + .index('by_tipo', ['tipoAtaque', 'timestamp']) + .index('by_severidade', ['severidade', 'timestamp']) + .index('by_status', ['status', 'timestamp']), + + incidentActions: defineTable({ + eventoId: v.id('securityEvents'), + tipo: acaoIncidenteTipo, + origem: v.union(v.literal('automatico'), v.literal('manual')), + status: acaoIncidenteStatus, + executadoPor: v.optional(v.id('usuarios')), + detalhes: v.optional(v.string()), + resultado: v.optional(v.string()), + relacionadoA: v.optional(v.id('ipReputation')), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_evento', ['eventoId', 'status']) + .index('by_tipo', ['tipo', 'status']), + + reportRequests: defineTable({ + solicitanteId: v.id('usuarios'), + filtros: v.object({ + dataInicio: v.number(), + dataFim: v.number(), + severidades: v.optional(v.array(severidadeSeguranca)), + tiposAtaque: v.optional(v.array(ataqueCiberneticoTipo)), + incluirIndicadores: v.optional(v.boolean()), + incluirMetricas: v.optional(v.boolean()), + incluirAcoes: v.optional(v.boolean()) + }), + status: reportStatus, + resultadoId: v.optional(v.id('_storage')), + observacoes: v.optional(v.string()), + criadoEm: v.number(), + atualizadoEm: v.number(), + concluidoEm: v.optional(v.number()), + erro: v.optional(v.string()) + }) + .index('by_status', ['status']) + .index('by_solicitante', ['solicitanteId', 'status']) + .index('by_criado_em', ['criadoEm']), + + rateLimitConfig: defineTable({ + nome: v.string(), + tipo: v.union( + v.literal('ip'), + v.literal('usuario'), + v.literal('endpoint'), + v.literal('global') + ), + identificador: v.optional(v.string()), + limite: v.number(), + janelaSegundos: v.number(), + estrategia: v.union( + v.literal('fixed_window'), + v.literal('sliding_window'), + v.literal('token_bucket') + ), + acaoExcedido: v.union(v.literal('bloquear'), v.literal('throttle'), v.literal('alertar')), + bloqueioTemporarioSegundos: v.optional(v.number()), + ativo: v.boolean(), + prioridade: v.number(), + criadoPor: v.id('usuarios'), + atualizadoPor: v.optional(v.id('usuarios')), + criadoEm: v.number(), + atualizadoEm: v.number(), + notas: v.optional(v.string()), + tags: v.optional(v.array(v.string())) + }) + .index('by_tipo_identificador', ['tipo', 'identificador']) + .index('by_ativo', ['ativo']) + .index('by_prioridade', ['prioridade']), + alertConfigs: defineTable({ + nome: v.string(), + canais: v.object({ + email: v.boolean(), + chat: v.boolean() + }), + emails: v.array(v.string()), + chatUsers: v.array(v.string()), + severidadeMin: severidadeSeguranca, + tiposAtaque: v.optional(v.array(ataqueCiberneticoTipo)), + reenvioMin: v.number(), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoEm: v.number() + }).index('by_criadoEm', ['criadoEm']), + + // Sistema de Controle de Ponto + registrosPonto: defineTable({ + funcionarioId: v.id('funcionarios'), + tipo: v.union( + v.literal('entrada'), + v.literal('saida_almoco'), + v.literal('retorno_almoco'), + v.literal('saida') + ), + data: v.string(), // YYYY-MM-DD + hora: v.number(), + minuto: v.number(), + segundo: v.number(), + timestamp: v.number(), // Timestamp completo para ordenação + imagemId: v.optional(v.id('_storage')), + sincronizadoComServidor: v.boolean(), + toleranciaMinutos: v.number(), + dentroDoPrazo: v.boolean(), + + // Informações de Rede + ipAddress: v.optional(v.string()), + ipPublico: v.optional(v.string()), + ipLocal: v.optional(v.string()), + + // Informações do Navegador + userAgent: v.optional(v.string()), + browser: v.optional(v.string()), + browserVersion: v.optional(v.string()), + engine: v.optional(v.string()), + + // Informações do Sistema + sistemaOperacional: v.optional(v.string()), + osVersion: v.optional(v.string()), + arquitetura: v.optional(v.string()), + plataforma: v.optional(v.string()), + + // Informações de Localização + latitude: v.optional(v.number()), + longitude: v.optional(v.number()), + precisao: v.optional(v.number()), + altitude: v.optional(v.union(v.number(), v.null())), + altitudeAccuracy: v.optional(v.union(v.number(), v.null())), + heading: v.optional(v.union(v.number(), v.null())), + speed: v.optional(v.union(v.number(), v.null())), + confiabilidadeGPS: v.optional(v.number()), // 0-1 (frontend) + scoreConfiancaBackend: v.optional(v.number()), // 0-1 (backend) + suspeitaSpoofing: v.optional(v.boolean()), + motivoSuspeita: v.optional(v.string()), + avisosValidacao: v.optional(v.array(v.string())), // Array de avisos detalhados da validação + distanciaIPvsGPS: v.optional(v.number()), // Distância em metros entre IP geolocation e GPS + velocidadeUltimoRegistro: v.optional(v.number()), // Velocidade em km/h do último registro + distanciaUltimoRegistro: v.optional(v.number()), // Distância em metros do último registro + tempoDecorridoHoras: v.optional(v.number()), // Tempo em horas desde o último registro + // Informações de Geofencing + enderecoMarcacaoEsperado: v.optional(v.id('enderecosMarcacao')), // Endereço mais próximo esperado + distanciaEnderecoEsperado: v.optional(v.number()), // Distância em metros do endereço esperado + dentroRaioPermitido: v.optional(v.boolean()), // Se está dentro do raio permitido + enderecoMarcacaoUsado: v.optional(v.id('enderecosMarcacao')), // Qual endereço foi usado na validação + raioToleranciaUsado: v.optional(v.number()), // Raio usado na validação em metros + endereco: v.optional(v.string()), + cidade: v.optional(v.string()), + estado: v.optional(v.string()), + pais: v.optional(v.string()), + timezone: v.optional(v.string()), + + // Informações do Dispositivo + deviceType: v.optional(v.string()), + deviceModel: v.optional(v.string()), + screenResolution: v.optional(v.string()), + coresTela: v.optional(v.number()), + idioma: v.optional(v.string()), + + // Informações Adicionais + isMobile: v.optional(v.boolean()), + isTablet: v.optional(v.boolean()), + isDesktop: v.optional(v.boolean()), + connectionType: v.optional(v.string()), + memoryInfo: v.optional(v.string()), + + // Informações de Sensores (Acelerômetro e Giroscópio) + acelerometroX: v.optional(v.number()), + acelerometroY: v.optional(v.number()), + acelerometroZ: v.optional(v.number()), + movimentoDetectado: v.optional(v.boolean()), + magnitudeMovimento: v.optional(v.number()), + variacaoAcelerometro: v.optional(v.number()), + giroscopioAlpha: v.optional(v.number()), + giroscopioBeta: v.optional(v.number()), + giroscopioGamma: v.optional(v.number()), + sensorDisponivel: v.optional(v.boolean()), + permissaoSensorNegada: v.optional(v.boolean()), + + // Justificativa opcional para o registro + justificativa: v.optional(v.string()), + + // Campos para homologação + editadoPorGestor: v.optional(v.boolean()), + homologacaoId: v.optional(v.id('homologacoesPonto')), + + criadoEm: v.number() + }) + .index('by_funcionario_data', ['funcionarioId', 'data']) + .index('by_data', ['data']) + .index('by_dentro_prazo', ['dentroDoPrazo', 'data']) + .index('by_funcionario_timestamp', ['funcionarioId', 'timestamp']), + + // Endereços de Marcação - Locais permitidos para registro de ponto + enderecosMarcacao: defineTable({ + nome: v.string(), // Ex: "Sede Principal", "Home Office João Silva", "Cliente ABC" + descricao: v.optional(v.string()), // Descrição opcional + // Coordenadas (obrigatórias) + latitude: v.number(), + longitude: v.number(), + // Endereço físico (para exibição) + endereco: v.string(), // Ex: "Rua Exemplo, 123" + bairro: v.optional(v.string()), // Bairro do endereço + cep: v.optional(v.string()), + cidade: v.string(), + estado: v.string(), + pais: v.optional(v.string()), // Padrão: "Brasil" + // Configurações + raioMetros: v.number(), // Raio de tolerância em metros (ex: 100m, 500m, 1000m) + ativo: v.boolean(), + // Tipos de uso + tipo: v.union( + v.literal('sede'), // Sede principal (para todos) + v.literal('home_office'), // Home office específico + v.literal('deslocamento'), // Deslocamento temporário + v.literal('cliente') // Local de cliente + ), + // Metadados + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoPor: v.optional(v.id('usuarios')), + atualizadoEm: v.optional(v.number()) + }) + .index('by_ativo', ['ativo']) + .index('by_tipo', ['tipo']) + .index('by_cidade', ['cidade']), + + // Associação Funcionário ↔ Endereço de Marcação + funcionarioEnderecosMarcacao: defineTable({ + funcionarioId: v.id('funcionarios'), + enderecoMarcacaoId: v.id('enderecosMarcacao'), + // Configurações específicas do funcionário + raioMetrosPersonalizado: v.optional(v.number()), // Pode ter raio diferente do padrão + // Período de validade (para deslocamentos temporários) + dataInicio: v.optional(v.string()), // YYYY-MM-DD + dataFim: v.optional(v.string()), // YYYY-MM-DD + // Status + ativo: v.boolean(), + // Metadados + criadoPor: v.id('usuarios'), + criadoEm: v.number() + }) + .index('by_funcionario', ['funcionarioId']) + .index('by_endereco', ['enderecoMarcacaoId']) + .index('by_funcionario_ativo', ['funcionarioId', 'ativo']) + .index('by_endereco_ativo', ['enderecoMarcacaoId', 'ativo']), + + configuracaoPonto: defineTable({ + horarioEntrada: v.string(), // HH:mm + horarioSaidaAlmoco: v.string(), // HH:mm + horarioRetornoAlmoco: v.string(), // HH:mm + horarioSaida: v.string(), // HH:mm + toleranciaMinutos: v.number(), + // Nomes personalizados dos tipos de registro + nomeEntrada: v.optional(v.string()), // Padrão: "Entrada 1" + nomeSaidaAlmoco: v.optional(v.string()), // Padrão: "Saída 1" + nomeRetornoAlmoco: v.optional(v.string()), // Padrão: "Entrada 2" + nomeSaida: v.optional(v.string()), // Padrão: "Saída 2" + // Ajuste de fuso horário (GMT offset em horas) + gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC) + // Configurações de geofencing + validarLocalizacao: v.optional(v.boolean()), // Habilitar/desabilitar validação de localização + toleranciaDistanciaMetros: v.optional(v.number()), // Raio padrão global em metros + ativo: v.boolean(), + atualizadoPor: v.id('usuarios'), + atualizadoEm: v.number() + }).index('by_ativo', ['ativo']), + + configuracaoRelogio: defineTable({ + servidorNTP: v.optional(v.string()), + portaNTP: v.optional(v.number()), + usarServidorExterno: v.boolean(), + fallbackParaPC: v.boolean(), + ultimaSincronizacao: v.optional(v.number()), + offsetSegundos: v.optional(v.number()), + // Ajuste de fuso horário (GMT offset em horas) + gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC) + atualizadoPor: v.id('usuarios'), + atualizadoEm: v.number() + }).index('by_ativo', ['usarServidorExterno']), + + // Banco de Horas - Saldo diário de horas trabalhadas + bancoHoras: defineTable({ + funcionarioId: v.id('funcionarios'), + data: v.string(), // YYYY-MM-DD + cargaHorariaDiaria: v.number(), // Horas esperadas do dia (em minutos) + horasTrabalhadas: v.number(), // Horas realmente trabalhadas (em minutos) + saldoMinutos: v.number(), // Saldo do dia (positivo = horas extras, negativo = déficit) + registrosPontoIds: v.array(v.id('registrosPonto')), // IDs dos registros do dia + calculadoEm: v.number() + }) + .index('by_funcionario_data', ['funcionarioId', 'data']) + .index('by_funcionario', ['funcionarioId']) + .index('by_data', ['data']), + + // Homologações de Ponto - Edições e ajustes realizados pelo gestor + homologacoesPonto: defineTable({ + registroId: v.optional(v.id('registrosPonto')), // ID do registro editado (se for edição) + funcionarioId: v.id('funcionarios'), + gestorId: v.id('usuarios'), + // Dados do registro original (se for edição) + horaAnterior: v.optional(v.number()), + minutoAnterior: v.optional(v.number()), + // Dados do registro novo (se for edição) + horaNova: v.optional(v.number()), + minutoNova: v.optional(v.number()), + // Motivo e observações + motivoId: v.optional(v.string()), // ID do motivo (referência a atestados/declarações) + motivoTipo: v.optional(v.string()), // Tipo do motivo (atestado, declaracao, etc) + motivoDescricao: v.optional(v.string()), // Descrição do motivo + observacoes: v.optional(v.string()), + // Tipo de ajuste (se for ajuste de banco de horas) + tipoAjuste: v.optional( + v.union(v.literal('compensar'), v.literal('abonar'), v.literal('descontar')) + ), + // Período do ajuste (se for ajuste de banco de horas) + periodoDias: v.optional(v.number()), + periodoHoras: v.optional(v.number()), + periodoMinutos: v.optional(v.number()), + // Ajuste em minutos (calculado) + ajusteMinutos: v.optional(v.number()), + criadoEm: v.number() + }) + .index('by_funcionario', ['funcionarioId']) + .index('by_gestor', ['gestorId']) + .index('by_registro', ['registroId']) + .index('by_data', ['criadoEm']), + + // Dispensas de Registro - Períodos onde funcionário está dispensado de registrar ponto + dispensasRegistro: defineTable({ + funcionarioId: v.id('funcionarios'), + gestorId: v.id('usuarios'), + dataInicio: v.string(), // YYYY-MM-DD + horaInicio: v.number(), + minutoInicio: v.number(), + dataFim: v.string(), // YYYY-MM-DD + horaFim: v.number(), + minutoFim: v.number(), + motivo: v.string(), + isento: v.boolean(), // Se true, não expira (casos excepcionais) + ativo: v.boolean(), + criadoEm: v.number() + }) + .index('by_funcionario', ['funcionarioId']) + .index('by_gestor', ['gestorId']) + .index('by_ativo', ['ativo']) + .index('by_data_inicio', ['dataInicio']) + .index('by_data_fim', ['dataFim']), + // Configurações Gerais + config: defineTable({ + comprasSetorId: v.optional(v.id('setores')), + criadoPor: v.id('usuarios'), + atualizadoEm: v.number() + }), + + // Módulo de Pedidos/Compras + produtos: defineTable({ + nome: v.string(), + valorEstimado: v.string(), + tipo: v.union(v.literal('servico'), v.literal('estrutura'), v.literal('insumo')), + criadoPor: v.id('usuarios'), + criadoEm: v.number() + }) + .searchIndex('search_nome', { searchField: 'nome' }) + .index('by_nome', ['nome']) + .index('by_tipo', ['tipo']), + + acoes: defineTable({ + nome: v.string(), + tipo: v.union(v.literal('projeto'), v.literal('lei')), + criadoPor: v.id('usuarios'), + criadoEm: v.number() + }) + .index('by_nome', ['nome']) + .index('by_tipo', ['tipo']), + + pedidos: defineTable({ + numeroSei: v.optional(v.string()), + status: v.union( + v.literal('em_rascunho'), + v.literal('aguardando_aceite'), + v.literal('em_analise'), + v.literal('precisa_ajustes'), + v.literal('cancelado'), + v.literal('concluido') + ), + acaoId: v.optional(v.id('acoes')), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_numeroSei', ['numeroSei']) + .index('by_status', ['status']) + .index('by_criadoPor', ['criadoPor']) + .index('by_acaoId', ['acaoId']), + + pedidoItems: defineTable({ + pedidoId: v.id('pedidos'), + produtoId: v.id('produtos'), + valorEstimado: v.string(), + valorReal: v.optional(v.string()), + quantidade: v.number(), + adicionadoPor: v.id('funcionarios'), + criadoEm: v.number() + }) + .index('by_pedidoId', ['pedidoId']) + .index('by_produtoId', ['produtoId']) + .index('by_adicionadoPor', ['adicionadoPor']), + + historicoPedidos: defineTable({ + pedidoId: v.id('pedidos'), + usuarioId: v.id('usuarios'), + acao: v.string(), // "criacao", "alteracao_status", "adicao_item", "remocao_item", "edicao_item" + detalhes: v.optional(v.string()), // JSON string + data: v.number() + }) + .index('by_pedidoId', ['pedidoId']) + .index('by_usuarioId', ['usuarioId']) + .index('by_data', ['data']) });