feat: update ESLint and TypeScript configurations across frontend and backend; enhance component structure and improve data handling in various modules

This commit is contained in:
2025-12-02 16:36:02 -03:00
parent f48d28067c
commit d79e6959c3
215 changed files with 29474 additions and 28173 deletions

View File

@@ -7,29 +7,29 @@ A query function that takes two arguments looks like:
```ts
// functions.js
import { query } from "./_generated/server";
import { v } from "convex/values";
import { query } from './_generated/server';
import { v } from 'convex/values';
export const myQueryFunction = query({
// Validators for arguments.
args: {
first: v.number(),
second: v.string(),
},
// Validators for arguments.
args: {
first: v.number(),
second: v.string()
},
// Function implementation.
handler: async (ctx, args) => {
// Read the database as many times as you need here.
// See https://docs.convex.dev/database/reading-data.
const documents = await ctx.db.query("tablename").collect();
// Function implementation.
handler: async (ctx, args) => {
// Read the database as many times as you need here.
// See https://docs.convex.dev/database/reading-data.
const documents = await ctx.db.query('tablename').collect();
// Arguments passed from the client are properties of the args object.
console.log(args.first, args.second);
// Arguments passed from the client are properties of the args object.
console.log(args.first, args.second);
// Write arbitrary JavaScript here: filter, aggregate, build derived data,
// remove non-public properties, or create new objects.
return documents;
},
// Write arbitrary JavaScript here: filter, aggregate, build derived data,
// remove non-public properties, or create new objects.
return documents;
}
});
```
@@ -37,8 +37,8 @@ Using this query function in a React component looks like:
```ts
const data = useQuery(api.functions.myQueryFunction, {
first: 10,
second: "hello",
first: 10,
second: 'hello'
});
```
@@ -46,27 +46,27 @@ A mutation function looks like:
```ts
// functions.js
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { mutation } from './_generated/server';
import { v } from 'convex/values';
export const myMutationFunction = mutation({
// Validators for arguments.
args: {
first: v.string(),
second: v.string(),
},
// Validators for arguments.
args: {
first: v.string(),
second: v.string()
},
// Function implementation.
handler: async (ctx, args) => {
// Insert or modify documents in the database here.
// Mutations can also read from the database like queries.
// See https://docs.convex.dev/database/writing-data.
const message = { body: args.first, author: args.second };
const id = await ctx.db.insert("messages", message);
// Function implementation.
handler: async (ctx, args) => {
// Insert or modify documents in the database here.
// Mutations can also read from the database like queries.
// See https://docs.convex.dev/database/writing-data.
const message = { body: args.first, author: args.second };
const id = await ctx.db.insert('messages', message);
// Optionally, return a value from your mutation.
return await ctx.db.get(id);
},
// Optionally, return a value from your mutation.
return await ctx.db.get(id);
}
});
```
@@ -75,13 +75,11 @@ Using this mutation function in a React component looks like:
```ts
const mutation = useMutation(api.functions.myMutationFunction);
function handleButtonPress() {
// fire and forget, the most common way to use mutations
mutation({ first: "Hello!", second: "me" });
// OR
// use the result once the mutation has completed
mutation({ first: "Hello!", second: "me" }).then((result) =>
console.log(result),
);
// fire and forget, the most common way to use mutations
mutation({ first: 'Hello!', second: 'me' });
// OR
// use the result once the mutation has completed
mutation({ first: 'Hello!', second: 'me' }).then((result) => console.log(result));
}
```

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@
* @module
*/
import { anyApi, componentsGeneric } from "convex/server";
import { anyApi, componentsGeneric } from 'convex/server';
/**
* A utility for referencing Convex functions in your app's API.

View File

@@ -9,13 +9,13 @@
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames
} from 'convex/server';
import type { GenericId } from 'convex/values';
import schema from '../schema.js';
/**
* The names of all of your Convex tables.
@@ -27,10 +27,7 @@ export type TableNames = TableNamesInDataModel<DataModel>;
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
export type Doc<TableName extends TableNames> = DocumentByName<DataModel, TableName>;
/**
* An identifier for a document in Convex.
@@ -45,8 +42,7 @@ export type Doc<TableName extends TableNames> = DocumentByName<
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
export type Id<TableName extends TableNames | SystemTableNames> = GenericId<TableName>;
/**
* A type describing your Convex data model.

View File

@@ -9,17 +9,17 @@
*/
import {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter
} from 'convex/server';
import type { DataModel } from './dataModel.js';
/**
* Define a query in this Convex app's public API.
@@ -29,7 +29,7 @@ import type { DataModel } from "./dataModel.js";
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const query: QueryBuilder<DataModel, "public">;
export declare const query: QueryBuilder<DataModel, 'public'>;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
@@ -39,7 +39,7 @@ export declare const query: QueryBuilder<DataModel, "public">;
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
export declare const internalQuery: QueryBuilder<DataModel, 'internal'>;
/**
* Define a mutation in this Convex app's public API.
@@ -49,7 +49,7 @@ export declare const internalQuery: QueryBuilder<DataModel, "internal">;
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const mutation: MutationBuilder<DataModel, "public">;
export declare const mutation: MutationBuilder<DataModel, 'public'>;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
@@ -59,7 +59,7 @@ export declare const mutation: MutationBuilder<DataModel, "public">;
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
export declare const internalMutation: MutationBuilder<DataModel, 'internal'>;
/**
* Define an action in this Convex app's public API.
@@ -72,7 +72,7 @@ export declare const internalMutation: MutationBuilder<DataModel, "internal">;
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export declare const action: ActionBuilder<DataModel, "public">;
export declare const action: ActionBuilder<DataModel, 'public'>;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
@@ -80,7 +80,7 @@ export declare const action: ActionBuilder<DataModel, "public">;
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export declare const internalAction: ActionBuilder<DataModel, "internal">;
export declare const internalAction: ActionBuilder<DataModel, 'internal'>;
/**
* Define an HTTP action.

View File

@@ -9,14 +9,14 @@
*/
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric
} from 'convex/server';
/**
* Define a query in this Convex app's public API.

View File

@@ -1,233 +1,216 @@
"use node";
'use node';
import { action } from "../_generated/server";
import { v } from "convex/values";
import { internal } from "../_generated/api";
import { decryptSMTPPasswordNode } from "./utils/nodeCrypto";
import nodemailer from "nodemailer";
import { action } from '../_generated/server';
import { v } from 'convex/values';
import { internal } from '../_generated/api';
import { decryptSMTPPasswordNode } from './utils/nodeCrypto';
import nodemailer from 'nodemailer';
export const enviar = action({
args: {
emailId: v.id("notificacoesEmail"),
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
"use node";
args: {
emailId: v.id('notificacoesEmail')
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
'use node';
let email;
try {
// Buscar email da fila
email = await ctx.runQuery(internal.email.getEmailById, {
emailId: args.emailId,
});
let email;
try {
// Buscar email da fila
email = await ctx.runQuery(internal.email.getEmailById, {
emailId: args.emailId
});
if (!email) {
return { sucesso: false, erro: "Email não encontrado" };
}
if (!email) {
return { sucesso: false, erro: 'Email não encontrado' };
}
// Buscar configuração SMTP ativa
const configRaw = await ctx.runQuery(
internal.email.getActiveEmailConfig,
{}
);
// Buscar configuração SMTP ativa
const configRaw = await ctx.runQuery(internal.email.getActiveEmailConfig, {});
if (!configRaw) {
console.error(
"❌ Configuração SMTP não encontrada ou inativa para email:",
email.destinatario
);
return {
sucesso: false,
erro: "Configuração de email não encontrada ou inativa. Verifique as configurações SMTP no painel de TI.",
};
}
if (!configRaw) {
console.error(
'❌ Configuração SMTP não encontrada ou inativa para email:',
email.destinatario
);
return {
sucesso: false,
erro: 'Configuração de email não encontrada ou inativa. Verifique as configurações SMTP no painel de TI.'
};
}
console.log("📧 Tentando enviar email:", {
para: email.destinatario,
assunto: email.assunto,
servidor: configRaw.servidor,
porta: configRaw.porta,
});
console.log('📧 Tentando enviar email:', {
para: email.destinatario,
assunto: email.assunto,
servidor: configRaw.servidor,
porta: configRaw.porta
});
// Descriptografar senha usando função compatível com Node.js
let senhaDescriptografada: string;
try {
senhaDescriptografada = await decryptSMTPPasswordNode(
configRaw.senhaHash
);
} catch (decryptError) {
const decryptErrorMessage =
decryptError instanceof Error
? decryptError.message
: String(decryptError);
console.error(
"Erro ao descriptografar senha SMTP:",
decryptErrorMessage
);
return {
sucesso: false,
erro: `Erro ao descriptografar senha SMTP: ${decryptErrorMessage}`,
};
}
// Descriptografar senha usando função compatível com Node.js
let senhaDescriptografada: string;
try {
senhaDescriptografada = await decryptSMTPPasswordNode(configRaw.senhaHash);
} catch (decryptError) {
const decryptErrorMessage =
decryptError instanceof Error ? decryptError.message : String(decryptError);
console.error('Erro ao descriptografar senha SMTP:', decryptErrorMessage);
return {
sucesso: false,
erro: `Erro ao descriptografar senha SMTP: ${decryptErrorMessage}`
};
}
const config = {
...configRaw,
senha: senhaDescriptografada,
};
const config = {
...configRaw,
senha: senhaDescriptografada
};
// Config já foi validado acima
// Config já foi validado acima
// Avisar mas não bloquear se não foi testado
if (!config.testadoEm) {
console.warn(
"⚠️ Configuração SMTP não foi testada. Tentando enviar mesmo assim..."
);
}
// Avisar mas não bloquear se não foi testado
if (!config.testadoEm) {
console.warn('⚠️ Configuração SMTP não foi testada. Tentando enviar mesmo assim...');
}
// Marcar como enviando
await ctx.runMutation(internal.email.markEmailEnviando, {
emailId: args.emailId,
});
// Marcar como enviando
await ctx.runMutation(internal.email.markEmailEnviando, {
emailId: args.emailId
});
// Criar transporter do nodemailer com configuração melhorada
const transporterOptions: {
host: string;
port: number;
secure: boolean;
requireTLS?: boolean;
auth: {
user: string;
pass: string;
};
tls?: {
rejectUnauthorized: boolean;
ciphers?: string;
};
connectionTimeout: number;
greetingTimeout: number;
socketTimeout: number;
pool?: boolean;
maxConnections?: number;
maxMessages?: number;
} = {
host: config.servidor,
port: config.porta,
secure: config.usarSSL,
auth: {
user: config.usuario,
pass: config.senha, // Senha já descriptografada
},
connectionTimeout: 15000, // 15 segundos
greetingTimeout: 15000,
socketTimeout: 15000,
pool: true, // Usar pool de conexões
maxConnections: 5,
maxMessages: 100,
};
// Criar transporter do nodemailer com configuração melhorada
const transporterOptions: {
host: string;
port: number;
secure: boolean;
requireTLS?: boolean;
auth: {
user: string;
pass: string;
};
tls?: {
rejectUnauthorized: boolean;
ciphers?: string;
};
connectionTimeout: number;
greetingTimeout: number;
socketTimeout: number;
pool?: boolean;
maxConnections?: number;
maxMessages?: number;
} = {
host: config.servidor,
port: config.porta,
secure: config.usarSSL,
auth: {
user: config.usuario,
pass: config.senha // Senha já descriptografada
},
connectionTimeout: 15000, // 15 segundos
greetingTimeout: 15000,
socketTimeout: 15000,
pool: true, // Usar pool de conexões
maxConnections: 5,
maxMessages: 100
};
// Adicionar TLS apenas se necessário
if (config.usarTLS) {
transporterOptions.requireTLS = true;
transporterOptions.tls = {
rejectUnauthorized: false, // Permitir certificados autoassinados
};
} else if (config.usarSSL) {
transporterOptions.tls = {
rejectUnauthorized: false,
};
}
// Adicionar TLS apenas se necessário
if (config.usarTLS) {
transporterOptions.requireTLS = true;
transporterOptions.tls = {
rejectUnauthorized: false // Permitir certificados autoassinados
};
} else if (config.usarSSL) {
transporterOptions.tls = {
rejectUnauthorized: false
};
}
const transporter = nodemailer.createTransport(transporterOptions);
const transporter = nodemailer.createTransport(transporterOptions);
// Verificar conexão antes de enviar
try {
await transporter.verify();
console.log("✅ Conexão SMTP verificada com sucesso");
} catch (verifyError) {
const verifyErrorMessage =
verifyError instanceof Error
? verifyError.message
: String(verifyError);
console.warn(
"⚠️ Falha na verificação SMTP, mas tentando enviar mesmo assim:",
verifyErrorMessage
);
// Não bloquear envio por falha na verificação, apenas avisar
}
// Verificar conexão antes de enviar
try {
await transporter.verify();
console.log('✅ Conexão SMTP verificada com sucesso');
} catch (verifyError) {
const verifyErrorMessage =
verifyError instanceof Error ? verifyError.message : String(verifyError);
console.warn(
'⚠️ Falha na verificação SMTP, mas tentando enviar mesmo assim:',
verifyErrorMessage
);
// Não bloquear envio por falha na verificação, apenas avisar
}
// Validar email destinatário antes de enviar
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email.destinatario)) {
throw new Error(`Email destinatário inválido: ${email.destinatario}`);
}
// Validar email destinatário antes de enviar
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email.destinatario)) {
throw new Error(`Email destinatário inválido: ${email.destinatario}`);
}
// Criar versão texto do HTML (remover tags e decodificar entidades básicas)
const textoPlano = email.corpo
.replace(/<[^>]*>/g, "") // Remover tags HTML
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
// Criar versão texto do HTML (remover tags e decodificar entidades básicas)
const textoPlano = email.corpo
.replace(/<[^>]*>/g, '') // Remover tags HTML
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
// Enviar email
const info = await transporter.sendMail({
from: `"${config.nomeRemetente}" <${config.emailRemetente}>`,
to: email.destinatario,
subject: email.assunto,
html: email.corpo,
text: textoPlano || email.assunto, // Versão texto para clientes que não suportam HTML
headers: {
"X-Mailer": "SGSE-Sistema-de-Gerenciamento-de-Secretaria",
"X-Priority": "3",
},
});
// Enviar email
const info = await transporter.sendMail({
from: `"${config.nomeRemetente}" <${config.emailRemetente}>`,
to: email.destinatario,
subject: email.assunto,
html: email.corpo,
text: textoPlano || email.assunto, // Versão texto para clientes que não suportam HTML
headers: {
'X-Mailer': 'SGSE-Sistema-de-Gerenciamento-de-Secretaria',
'X-Priority': '3'
}
});
interface MessageInfo {
messageId?: string;
response?: string;
}
interface MessageInfo {
messageId?: string;
response?: string;
}
const messageInfo = info as MessageInfo;
const messageInfo = info as MessageInfo;
console.log("✅ Email enviado com sucesso!", {
para: email.destinatario,
assunto: email.assunto,
messageId: messageInfo.messageId,
response: messageInfo.response,
});
console.log('✅ Email enviado com sucesso!', {
para: email.destinatario,
assunto: email.assunto,
messageId: messageInfo.messageId,
response: messageInfo.response
});
// Marcar como enviado
await ctx.runMutation(internal.email.markEmailEnviado, {
emailId: args.emailId,
});
// Marcar como enviado
await ctx.runMutation(internal.email.markEmailEnviado, {
emailId: args.emailId
});
return { sucesso: true };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
return { sucesso: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
console.error("❌ Erro ao enviar email:", {
emailId: args.emailId,
destinatario: email?.destinatario,
erro: errorMessage,
stack: errorStack,
});
console.error('❌ Erro ao enviar email:', {
emailId: args.emailId,
destinatario: email?.destinatario,
erro: errorMessage,
stack: errorStack
});
// Marcar como falha com detalhes completos
const erroCompleto = errorStack
? `${errorMessage}\n\nStack: ${errorStack}`
: errorMessage;
// Marcar como falha com detalhes completos
const erroCompleto = errorStack ? `${errorMessage}\n\nStack: ${errorStack}` : errorMessage;
await ctx.runMutation(internal.email.markEmailFalha, {
emailId: args.emailId,
erro: erroCompleto.substring(0, 2000), // Limitar tamanho do erro
});
await ctx.runMutation(internal.email.markEmailFalha, {
emailId: args.emailId,
erro: erroCompleto.substring(0, 2000) // Limitar tamanho do erro
});
return { sucesso: false, erro: errorMessage };
}
},
return { sucesso: false, erro: errorMessage };
}
}
});

View File

@@ -158,7 +158,7 @@
// // Configurar SSH
// let sshPasswordDecrypted: string | undefined = undefined;
// // Se senha foi fornecida, usar ela. Caso contrário, tentar descriptografar a armazenada
// if (args.sshPassword) {
// sshPasswordDecrypted = args.sshPassword;
@@ -429,4 +429,3 @@
// }
// },
// });

View File

@@ -1,138 +1,155 @@
"use node";
'use node';
import { action } from "../_generated/server";
import { v } from "convex/values";
import { internal } from "../_generated/api";
import { action } from '../_generated/server';
import { v } from 'convex/values';
import { internal } from '../_generated/api';
/**
* Extrair preview de link (metadados Open Graph) - função auxiliar
*/
async function extrairPreviewLinkHelper(url: string) {
try {
// Validar URL
let urlObj: URL;
try {
urlObj = new URL(url);
} catch {
return null;
}
try {
// Validar URL
let urlObj: URL;
try {
urlObj = new URL(url);
} catch {
return null;
}
// Buscar HTML da página
const response = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (compatible; SGSE-Bot/1.0)",
},
signal: AbortSignal.timeout(5000), // Timeout de 5 segundos
});
// Buscar HTML da página
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; SGSE-Bot/1.0)'
},
signal: AbortSignal.timeout(5000) // Timeout de 5 segundos
});
if (!response.ok) {
return null;
}
if (!response.ok) {
return null;
}
const html = await response.text();
const html = await response.text();
// Extrair metadados Open Graph e Twitter Cards
const metadata: {
titulo?: string;
descricao?: string;
imagem?: string;
site?: string;
} = {};
// Extrair metadados Open Graph e Twitter Cards
const metadata: {
titulo?: string;
descricao?: string;
imagem?: string;
site?: string;
} = {};
// Título (og:title ou twitter:title ou <title>)
const ogTitleMatch = html.match(/<meta\s+property=["']og:title["']\s+content=["']([^"']+)["']/i);
const twitterTitleMatch = html.match(/<meta\s+name=["']twitter:title["']\s+content=["']([^"']+)["']/i);
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
metadata.titulo = ogTitleMatch?.[1] || twitterTitleMatch?.[1] || titleMatch?.[1] || undefined;
if (metadata.titulo) {
metadata.titulo = metadata.titulo.trim().substring(0, 200);
}
// Título (og:title ou twitter:title ou <title>)
const ogTitleMatch = html.match(
/<meta\s+property=["']og:title["']\s+content=["']([^"']+)["']/i
);
const twitterTitleMatch = html.match(
/<meta\s+name=["']twitter:title["']\s+content=["']([^"']+)["']/i
);
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
// Descrição (og:description ou twitter:description ou meta description)
const ogDescMatch = html.match(/<meta\s+property=["']og:description["']\s+content=["']([^"']+)["']/i);
const twitterDescMatch = html.match(/<meta\s+name=["']twitter:description["']\s+content=["']([^"']+)["']/i);
const metaDescMatch = html.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i);
metadata.descricao = ogDescMatch?.[1] || twitterDescMatch?.[1] || metaDescMatch?.[1] || undefined;
if (metadata.descricao) {
metadata.descricao = metadata.descricao.trim().substring(0, 300);
}
metadata.titulo = ogTitleMatch?.[1] || twitterTitleMatch?.[1] || titleMatch?.[1] || undefined;
if (metadata.titulo) {
metadata.titulo = metadata.titulo.trim().substring(0, 200);
}
// Imagem (og:image ou twitter:image)
const ogImageMatch = html.match(/<meta\s+property=["']og:image["']\s+content=["']([^"']+)["']/i);
const twitterImageMatch = html.match(/<meta\s+name=["']twitter:image["']\s+content=["']([^"']+)["']/i);
const imageUrl = ogImageMatch?.[1] || twitterImageMatch?.[1];
if (imageUrl) {
// Resolver URL relativa
try {
metadata.imagem = new URL(imageUrl, url).href;
} catch {
metadata.imagem = imageUrl;
}
}
// Descrição (og:description ou twitter:description ou meta description)
const ogDescMatch = html.match(
/<meta\s+property=["']og:description["']\s+content=["']([^"']+)["']/i
);
const twitterDescMatch = html.match(
/<meta\s+name=["']twitter:description["']\s+content=["']([^"']+)["']/i
);
const metaDescMatch = html.match(
/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i
);
// Site (og:site_name ou domínio)
const ogSiteMatch = html.match(/<meta\s+property=["']og:site_name["']\s+content=["']([^"']+)["']/i);
metadata.site = ogSiteMatch?.[1] || urlObj.hostname.replace(/^www\./, "");
metadata.descricao =
ogDescMatch?.[1] || twitterDescMatch?.[1] || metaDescMatch?.[1] || undefined;
if (metadata.descricao) {
metadata.descricao = metadata.descricao.trim().substring(0, 300);
}
return {
url,
titulo: metadata.titulo,
descricao: metadata.descricao,
imagem: metadata.imagem,
site: metadata.site,
};
} catch (error) {
console.error("Erro ao extrair preview de link:", error);
return null;
}
// Imagem (og:image ou twitter:image)
const ogImageMatch = html.match(
/<meta\s+property=["']og:image["']\s+content=["']([^"']+)["']/i
);
const twitterImageMatch = html.match(
/<meta\s+name=["']twitter:image["']\s+content=["']([^"']+)["']/i
);
const imageUrl = ogImageMatch?.[1] || twitterImageMatch?.[1];
if (imageUrl) {
// Resolver URL relativa
try {
metadata.imagem = new URL(imageUrl, url).href;
} catch {
metadata.imagem = imageUrl;
}
}
// Site (og:site_name ou domínio)
const ogSiteMatch = html.match(
/<meta\s+property=["']og:site_name["']\s+content=["']([^"']+)["']/i
);
metadata.site = ogSiteMatch?.[1] || urlObj.hostname.replace(/^www\./, '');
return {
url,
titulo: metadata.titulo,
descricao: metadata.descricao,
imagem: metadata.imagem,
site: metadata.site
};
} catch (error) {
console.error('Erro ao extrair preview de link:', error);
return null;
}
}
/**
* Processar preview de link e atualizar mensagem
*/
export const processarPreviewLink = action({
args: {
mensagemId: v.id("mensagens"),
url: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
// Extrair preview
const preview = await extrairPreviewLinkHelper(args.url);
if (preview) {
// Atualizar mensagem com preview
await ctx.runMutation(internal.chat.atualizarLinkPreview, {
mensagemId: args.mensagemId,
linkPreview: preview,
});
}
return null;
},
args: {
mensagemId: v.id('mensagens'),
url: v.string()
},
returns: v.null(),
handler: async (ctx, args) => {
// Extrair preview
const preview = await extrairPreviewLinkHelper(args.url);
if (preview) {
// Atualizar mensagem com preview
await ctx.runMutation(internal.chat.atualizarLinkPreview, {
mensagemId: args.mensagemId,
linkPreview: preview
});
}
return null;
}
});
/**
* Extrair preview de link (metadados Open Graph) - versão pública
*/
export const extrairPreviewLink = action({
args: {
url: v.string(),
},
returns: v.union(
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()),
}),
v.null()
),
handler: async (ctx, args) => {
return await extrairPreviewLinkHelper(args.url);
},
args: {
url: v.string()
},
returns: v.union(
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())
}),
v.null()
),
handler: async (ctx, args) => {
return await extrairPreviewLinkHelper(args.url);
}
});

View File

@@ -1,103 +1,102 @@
"use node";
'use node';
import { action } from "../_generated/server";
import { v } from "convex/values";
import { internal } from "../_generated/api";
import { action } from '../_generated/server';
import { v } from 'convex/values';
import { internal } from '../_generated/api';
/**
* Enviar push notification usando Web Push API
*/
export const enviarPush = action({
args: {
subscriptionId: v.id("pushSubscriptions"),
titulo: v.string(),
corpo: v.string(),
data: v.optional(
v.object({
conversaId: v.optional(v.string()),
mensagemId: v.optional(v.string()),
tipo: v.optional(v.string()),
})
),
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
try {
// Buscar subscription
const subscription = await ctx.runQuery(internal.pushNotifications.getSubscriptionById, {
subscriptionId: args.subscriptionId,
});
args: {
subscriptionId: v.id('pushSubscriptions'),
titulo: v.string(),
corpo: v.string(),
data: v.optional(
v.object({
conversaId: v.optional(v.string()),
mensagemId: v.optional(v.string()),
tipo: v.optional(v.string())
})
)
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
try {
// Buscar subscription
const subscription = await ctx.runQuery(internal.pushNotifications.getSubscriptionById, {
subscriptionId: args.subscriptionId
});
if (!subscription || !subscription.ativo) {
return { sucesso: false, erro: "Subscription não encontrada ou inativa" };
}
if (!subscription || !subscription.ativo) {
return { sucesso: false, erro: 'Subscription não encontrada ou inativa' };
}
// Web Push requer VAPID keys (deve estar em variáveis de ambiente)
// Por enquanto, vamos usar uma implementação básica
// Em produção, você precisará configurar VAPID keys
// Web Push requer VAPID keys (deve estar em variáveis de ambiente)
// Por enquanto, vamos usar uma implementação básica
// Em produção, você precisará configurar VAPID keys
const webpushModule = await import("web-push");
// web-push pode exportar como default ou named exports
// Usar a declaração de tipo do módulo web-push
interface WebPushType {
setVapidDetails: (subject: string, publicKey: string, privateKey: string) => void;
sendNotification: (
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
payload: string | Buffer
) => Promise<void>;
}
const webpush: WebPushType = (webpushModule.default || webpushModule) as WebPushType;
const webpushModule = await import('web-push');
// web-push pode exportar como default ou named exports
// Usar a declaração de tipo do módulo web-push
interface WebPushType {
setVapidDetails: (subject: string, publicKey: string, privateKey: string) => void;
sendNotification: (
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
payload: string | Buffer
) => Promise<void>;
}
const webpush: WebPushType = (webpushModule.default || webpushModule) as WebPushType;
// VAPID keys devem vir de variáveis de ambiente
const publicKey: string | undefined = process.env.VAPID_PUBLIC_KEY;
const privateKey: string | undefined = process.env.VAPID_PRIVATE_KEY;
// VAPID keys devem vir de variáveis de ambiente
const publicKey: string | undefined = process.env.VAPID_PUBLIC_KEY;
const privateKey: string | undefined = process.env.VAPID_PRIVATE_KEY;
if (!publicKey || !privateKey) {
console.warn("⚠️ VAPID keys não configuradas. Push notifications não funcionarão.");
// Em desenvolvimento, podemos retornar sucesso sem enviar
return { sucesso: true };
}
if (!publicKey || !privateKey) {
console.warn('⚠️ VAPID keys não configuradas. Push notifications não funcionarão.');
// Em desenvolvimento, podemos retornar sucesso sem enviar
return { sucesso: true };
}
webpush.setVapidDetails("mailto:suporte@sgse.app", publicKey, privateKey);
webpush.setVapidDetails('mailto:suporte@sgse.app', publicKey, privateKey);
// Preparar payload da notificação
const payload = JSON.stringify({
title: args.titulo,
body: args.corpo,
icon: "/favicon.png",
badge: "/favicon.png",
data: args.data || {},
tag: args.data?.conversaId || "default",
requireInteraction: args.data?.tipo === "mencao", // Menções requerem interação
});
// Preparar payload da notificação
const payload = JSON.stringify({
title: args.titulo,
body: args.corpo,
icon: '/favicon.png',
badge: '/favicon.png',
data: args.data || {},
tag: args.data?.conversaId || 'default',
requireInteraction: args.data?.tipo === 'mencao' // Menções requerem interação
});
// Enviar push notification
await webpush.sendNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
},
},
payload
);
// Enviar push notification
await webpush.sendNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth
}
},
payload
);
console.log(`✅ Push notification enviada para ${subscription.endpoint}`);
return { sucesso: true };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("❌ Erro ao enviar push notification:", errorMessage);
console.log(`✅ Push notification enviada para ${subscription.endpoint}`);
return { sucesso: true };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('❌ Erro ao enviar push notification:', errorMessage);
// Se subscription inválida, marcar como inativa
if (errorMessage.includes("410") || errorMessage.includes("expired")) {
await ctx.runMutation(internal.pushNotifications.marcarSubscriptionInativa, {
subscriptionId: args.subscriptionId,
});
}
// Se subscription inválida, marcar como inativa
if (errorMessage.includes('410') || errorMessage.includes('expired')) {
await ctx.runMutation(internal.pushNotifications.marcarSubscriptionInativa, {
subscriptionId: args.subscriptionId
});
}
return { sucesso: false, erro: errorMessage };
}
},
return { sucesso: false, erro: errorMessage };
}
}
});

View File

@@ -1,64 +1,61 @@
"use node";
import { action } from "../_generated/server";
import { v } from "convex/values";
'use node';
import { action } from '../_generated/server';
import { v } from 'convex/values';
// Importar nodemailer de forma estática para evitar problemas com caminhos no Windows
import nodemailer from "nodemailer";
import nodemailer from 'nodemailer';
export const testarConexao = action({
args: {
servidor: v.string(),
porta: v.number(),
usuario: v.string(),
senha: v.string(),
usarSSL: v.boolean(),
usarTLS: v.boolean(),
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
args: {
servidor: v.string(),
porta: v.number(),
usuario: v.string(),
senha: v.string(),
usarSSL: v.boolean(),
usarTLS: v.boolean()
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
try {
// Validações básicas
if (!args.servidor || args.servidor.trim() === '') {
return {
sucesso: false as const,
erro: 'Servidor SMTP não pode estar vazio'
};
}
try {
// Validações básicas
if (!args.servidor || args.servidor.trim() === "") {
return {
sucesso: false as const,
erro: "Servidor SMTP não pode estar vazio",
};
}
if (args.porta < 1 || args.porta > 65535) {
return { sucesso: false as const, erro: 'Porta inválida' };
}
if (args.porta < 1 || args.porta > 65535) {
return { sucesso: false as const, erro: "Porta inválida" };
}
const transporter = nodemailer.createTransport({
host: args.servidor,
port: args.porta,
secure: args.usarSSL,
auth: {
user: args.usuario,
pass: args.senha
},
tls: {
rejectUnauthorized: false
},
connectionTimeout: 10000, // 10 segundos
greetingTimeout: 10000,
socketTimeout: 10000
});
const transporter = nodemailer.createTransport({
host: args.servidor,
port: args.porta,
secure: args.usarSSL,
auth: {
user: args.usuario,
pass: args.senha,
},
tls: {
rejectUnauthorized: false,
},
connectionTimeout: 10000, // 10 segundos
greetingTimeout: 10000,
socketTimeout: 10000,
});
// Verificar conexão
await transporter.verify();
// Verificar conexão
await transporter.verify();
return { sucesso: true as const };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { sucesso: false as const, erro: errorMessage };
}
},
return { sucesso: true as const };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { sucesso: false as const, erro: errorMessage };
}
}
});

View File

@@ -1,4 +1,4 @@
"use node";
'use node';
/**
* Utilitários de criptografia compatíveis com Node.js
@@ -10,65 +10,64 @@
* Esta versão funciona em ambiente Node.js (actions)
*/
export async function decryptSMTPPasswordNode(encryptedPassword: string): Promise<string> {
try {
// Em Node.js, crypto.subtle está disponível globalmente
const crypto = globalThis.crypto;
if (!crypto || !crypto.subtle) {
throw new Error("Web Crypto API não disponível");
}
try {
// Em Node.js, crypto.subtle está disponível globalmente
const crypto = globalThis.crypto;
// Chave base - mesma usada em auth/utils.ts
const keyMaterial = new TextEncoder().encode("SGSE-EMAIL-ENCRYPTION-KEY-2024");
// Importar chave material
const keyMaterialKey = await crypto.subtle.importKey(
"raw",
keyMaterial,
{ name: "PBKDF2" },
false,
["deriveBits", "deriveKey"]
);
if (!crypto || !crypto.subtle) {
throw new Error('Web Crypto API não disponível');
}
// Derivar chave de 256 bits usando PBKDF2
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: new TextEncoder().encode("SGSE-SALT"),
iterations: 100000,
hash: "SHA-256",
},
keyMaterialKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
// Decodificar base64 manualmente (compatível com Node.js e browser)
const binaryString = atob(encryptedPassword);
const combined = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
// Chave base - mesma usada em auth/utils.ts
const keyMaterial = new TextEncoder().encode('SGSE-EMAIL-ENCRYPTION-KEY-2024');
// Extrair IV e dados criptografados
const iv = combined.slice(0, 12);
const encrypted = combined.slice(12);
// Importar chave material
const keyMaterialKey = await crypto.subtle.importKey(
'raw',
keyMaterial,
{ name: 'PBKDF2' },
false,
['deriveBits', 'deriveKey']
);
// Descriptografar
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: iv,
},
key,
encrypted
);
// Derivar chave de 256 bits usando PBKDF2
const key = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: new TextEncoder().encode('SGSE-SALT'),
iterations: 100000,
hash: 'SHA-256'
},
keyMaterialKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
// Converter para string
const decoder = new TextDecoder();
return decoder.decode(decrypted);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Erro ao descriptografar senha SMTP (Node.js):", errorMessage);
throw new Error(`Falha ao descriptografar senha SMTP: ${errorMessage}`);
}
// Decodificar base64 manualmente (compatível com Node.js e browser)
const binaryString = atob(encryptedPassword);
const combined = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
// Extrair IV e dados criptografados
const iv = combined.slice(0, 12);
const encrypted = combined.slice(12);
// Descriptografar
const decrypted = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: iv
},
key,
encrypted
);
// Converter para string
const decoder = new TextDecoder();
return decoder.decode(decrypted);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Erro ao descriptografar senha SMTP (Node.js):', errorMessage);
throw new Error(`Falha ao descriptografar senha SMTP: ${errorMessage}`);
}
}

View File

@@ -45,7 +45,7 @@ export const listarTodos = query({
try {
const funcionario = await ctx.db.get(a.funcionarioId);
const criadoPor = await ctx.db.get(a.criadoPor);
// Buscar foto do perfil do funcionário através do usuário associado
let fotoPerfilUrl: string | null = null;
if (funcionario) {
@@ -57,7 +57,7 @@ export const listarTodos = query({
fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil);
}
}
return {
...a,
funcionario,
@@ -88,7 +88,7 @@ export const listarTodos = query({
const licencaOriginal = l.licencaOriginalId
? await ctx.db.get(l.licencaOriginalId)
: null;
// Buscar foto do perfil do funcionário através do usuário associado
let fotoPerfilUrl: string | null = null;
if (funcionario) {
@@ -100,7 +100,7 @@ export const listarTodos = query({
fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil);
}
}
return {
...l,
funcionario,

File diff suppressed because it is too large Load Diff

View File

@@ -47,7 +47,7 @@ export const alterarSenha = mutation({
} catch (error: any) {
// Capturar erros específicos do Better Auth
let mensagemErro = 'Erro ao alterar senha';
if (error?.message) {
mensagemErro = error.message;
} else if (typeof error === 'string') {
@@ -55,10 +55,12 @@ export const alterarSenha = mutation({
}
// Mensagens de erro mais amigáveis
if (mensagemErro.toLowerCase().includes('password') ||
mensagemErro.toLowerCase().includes('senha') ||
mensagemErro.toLowerCase().includes('incorrect') ||
mensagemErro.toLowerCase().includes('incorreta')) {
if (
mensagemErro.toLowerCase().includes('password') ||
mensagemErro.toLowerCase().includes('senha') ||
mensagemErro.toLowerCase().includes('incorrect') ||
mensagemErro.toLowerCase().includes('incorreta')
) {
mensagemErro = 'Senha atual incorreta';
}
@@ -69,4 +71,3 @@ export const alterarSenha = mutation({
}
}
});

View File

@@ -1,8 +1,8 @@
export default {
providers: [
{
domain: process.env.CONVEX_SITE_URL,
applicationID: "convex",
},
],
providers: [
{
domain: process.env.CONVEX_SITE_URL,
applicationID: 'convex'
}
]
};

View File

@@ -7,127 +7,112 @@
* Gera um hash seguro de senha usando PBKDF2
*/
export async function hashPassword(password: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(password);
// Gerar salt aleatório
const salt = crypto.getRandomValues(new Uint8Array(16));
// Importar a senha como chave
const keyMaterial = await crypto.subtle.importKey(
"raw",
data,
"PBKDF2",
false,
["deriveBits"]
);
// Derivar a chave usando PBKDF2
const derivedBits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt: salt,
iterations: 100000,
hash: "SHA-256",
},
keyMaterial,
256
);
// Combinar salt + hash
const hashArray = new Uint8Array(derivedBits);
const combined = new Uint8Array(salt.length + hashArray.length);
combined.set(salt);
combined.set(hashArray, salt.length);
// Converter para base64
return btoa(String.fromCharCode(...combined));
const encoder = new TextEncoder();
const data = encoder.encode(password);
// Gerar salt aleatório
const salt = crypto.getRandomValues(new Uint8Array(16));
// Importar a senha como chave
const keyMaterial = await crypto.subtle.importKey('raw', data, 'PBKDF2', false, ['deriveBits']);
// Derivar a chave usando PBKDF2
const derivedBits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: salt,
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
256
);
// Combinar salt + hash
const hashArray = new Uint8Array(derivedBits);
const combined = new Uint8Array(salt.length + hashArray.length);
combined.set(salt);
combined.set(hashArray, salt.length);
// Converter para base64
return btoa(String.fromCharCode(...combined));
}
/**
* Verifica se uma senha corresponde ao hash
*/
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
try {
// Decodificar o hash de base64
const combined = Uint8Array.from(atob(hash), (c) => c.charCodeAt(0));
// Extrair salt e hash
const salt = combined.slice(0, 16);
const storedHash = combined.slice(16);
// Gerar hash da senha fornecida
const encoder = new TextEncoder();
const data = encoder.encode(password);
const keyMaterial = await crypto.subtle.importKey(
"raw",
data,
"PBKDF2",
false,
["deriveBits"]
);
const derivedBits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt: salt,
iterations: 100000,
hash: "SHA-256",
},
keyMaterial,
256
);
const newHash = new Uint8Array(derivedBits);
// Comparar os hashes
if (newHash.length !== storedHash.length) {
return false;
}
for (let i = 0; i < newHash.length; i++) {
if (newHash[i] !== storedHash[i]) {
return false;
}
}
return true;
} catch (error) {
console.error("Erro ao verificar senha:", error);
return false;
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
try {
// Decodificar o hash de base64
const combined = Uint8Array.from(atob(hash), (c) => c.charCodeAt(0));
// Extrair salt e hash
const salt = combined.slice(0, 16);
const storedHash = combined.slice(16);
// Gerar hash da senha fornecida
const encoder = new TextEncoder();
const data = encoder.encode(password);
const keyMaterial = await crypto.subtle.importKey('raw', data, 'PBKDF2', false, ['deriveBits']);
const derivedBits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: salt,
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
256
);
const newHash = new Uint8Array(derivedBits);
// Comparar os hashes
if (newHash.length !== storedHash.length) {
return false;
}
for (let i = 0; i < newHash.length; i++) {
if (newHash[i] !== storedHash[i]) {
return false;
}
}
return true;
} catch (error) {
console.error('Erro ao verificar senha:', error);
return false;
}
}
/**
* Gera um token aleatório seguro
*/
export function generateToken(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
/**
* Valida formato de matrícula (apenas números)
*/
export function validarMatricula(matricula: string): boolean {
return /^\d+$/.test(matricula) && matricula.length >= 3;
return /^\d+$/.test(matricula) && matricula.length >= 3;
}
/**
* Valida formato de senha (alfanuméricos e símbolos)
*/
export function validarSenha(senha: string): boolean {
// Mínimo 8 caracteres, pelo menos uma letra, um número e um símbolo
const regex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/;
return regex.test(senha);
// Mínimo 8 caracteres, pelo menos uma letra, um número e um símbolo
const regex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/;
return regex.test(senha);
}
/**
@@ -139,97 +124,93 @@ export function validarSenha(senha: string): boolean {
// Chave de criptografia derivada (em produção, deve vir de variável de ambiente)
// Para desenvolvimento, usando uma chave fixa. Em produção, deve ser configurada via env var.
const getEncryptionKey = async (): Promise<CryptoKey> => {
// Chave base - em produção, isso deve vir de process.env.ENCRYPTION_KEY
// Por enquanto, usando uma chave derivada de um valor fixo
const keyMaterial = new TextEncoder().encode("SGSE-EMAIL-ENCRYPTION-KEY-2024");
// Deriva uma chave de 256 bits usando PBKDF2
const key = await crypto.subtle.importKey(
"raw",
keyMaterial,
{ name: "PBKDF2" },
false,
["deriveBits", "deriveKey"]
);
// Chave base - em produção, isso deve vir de process.env.ENCRYPTION_KEY
// Por enquanto, usando uma chave derivada de um valor fixo
const keyMaterial = new TextEncoder().encode('SGSE-EMAIL-ENCRYPTION-KEY-2024');
return await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: new TextEncoder().encode("SGSE-SALT"),
iterations: 100000,
hash: "SHA-256",
},
key,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
// Deriva uma chave de 256 bits usando PBKDF2
const key = await crypto.subtle.importKey('raw', keyMaterial, { name: 'PBKDF2' }, false, [
'deriveBits',
'deriveKey'
]);
return await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: new TextEncoder().encode('SGSE-SALT'),
iterations: 100000,
hash: 'SHA-256'
},
key,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
};
/**
* Criptografa uma senha SMTP usando AES-GCM
*/
export async function encryptSMTPPassword(password: string): Promise<string> {
try {
const key = await getEncryptionKey();
const encoder = new TextEncoder();
const data = encoder.encode(password);
try {
const key = await getEncryptionKey();
const encoder = new TextEncoder();
const data = encoder.encode(password);
// Gerar IV (Initialization Vector) aleatório
const iv = crypto.getRandomValues(new Uint8Array(12));
// Gerar IV (Initialization Vector) aleatório
const iv = crypto.getRandomValues(new Uint8Array(12));
// Criptografar
const encrypted = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: iv,
},
key,
data
);
// Criptografar
const encrypted = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: iv
},
key,
data
);
// Combinar IV + dados criptografados e converter para base64
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
// Combinar IV + dados criptografados e converter para base64
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
return btoa(String.fromCharCode(...combined));
} catch (error) {
console.error("Erro ao criptografar senha SMTP:", error);
throw new Error("Falha ao criptografar senha SMTP");
}
return btoa(String.fromCharCode(...combined));
} catch (error) {
console.error('Erro ao criptografar senha SMTP:', error);
throw new Error('Falha ao criptografar senha SMTP');
}
}
/**
* Descriptografa uma senha SMTP usando AES-GCM
*/
export async function decryptSMTPPassword(encryptedPassword: string): Promise<string> {
try {
const key = await getEncryptionKey();
// Decodificar base64
const combined = Uint8Array.from(atob(encryptedPassword), (c) => c.charCodeAt(0));
try {
const key = await getEncryptionKey();
// Extrair IV e dados criptografados
const iv = combined.slice(0, 12);
const encrypted = combined.slice(12);
// Decodificar base64
const combined = Uint8Array.from(atob(encryptedPassword), (c) => c.charCodeAt(0));
// Descriptografar
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: iv,
},
key,
encrypted
);
// Extrair IV e dados criptografados
const iv = combined.slice(0, 12);
const encrypted = combined.slice(12);
// Converter para string
const decoder = new TextDecoder();
return decoder.decode(decrypted);
} catch (error) {
console.error("Erro ao descriptografar senha SMTP:", error);
throw new Error("Falha ao descriptografar senha SMTP");
}
// Descriptografar
const decrypted = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: iv
},
key,
encrypted
);
// Converter para string
const decoder = new TextDecoder();
return decoder.decode(decrypted);
} catch (error) {
console.error('Erro ao descriptografar senha SMTP:', error);
throw new Error('Falha ao descriptografar senha SMTP');
}
}

View File

@@ -35,8 +35,11 @@ async function gerarRoomName(
const roomPrefix = configJitsi?.roomPrefix || 'sgse';
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 9);
const conversaHash = conversaId.replace('conversas|', '').replace(/[^a-zA-Z0-9]/g, '').substring(0, 10);
const conversaHash = conversaId
.replace('conversas|', '')
.replace(/[^a-zA-Z0-9]/g, '')
.substring(0, 10);
return `${roomPrefix}-${tipo}-${conversaHash}-${timestamp}-${random}`;
}
@@ -93,10 +96,7 @@ export const criarChamada = mutation({
.query('chamadas')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId))
.filter((q) =>
q.or(
q.eq(q.field('status'), 'aguardando'),
q.eq(q.field('status'), 'em_andamento')
)
q.or(q.eq(q.field('status'), 'aguardando'), q.eq(q.field('status'), 'em_andamento'))
)
.collect();
@@ -123,11 +123,15 @@ export const criarChamada = mutation({
gravando: false,
configuracoes: {
audioHabilitado: args.audioHabilitado ?? true,
videoHabilitado: args.videoHabilitado ?? (args.tipo === 'video'),
videoHabilitado: args.videoHabilitado ?? args.tipo === 'video',
participantesConfig: conversa.participantes.map((participanteId) => ({
usuarioId: participanteId,
audioHabilitado: participanteId === usuarioAtual._id ? (args.audioHabilitado ?? true) : true,
videoHabilitado: participanteId === usuarioAtual._id ? (args.videoHabilitado ?? (args.tipo === 'video')) : (args.tipo === 'video'),
audioHabilitado:
participanteId === usuarioAtual._id ? (args.audioHabilitado ?? true) : true,
videoHabilitado:
participanteId === usuarioAtual._id
? (args.videoHabilitado ?? args.tipo === 'video')
: args.tipo === 'video',
forcadoPeloAnfitriao: false
}))
},
@@ -280,15 +284,18 @@ export const adicionarParticipante = mutation({
// Atualizar participantes
const novosParticipantes = [...chamada.participantes, args.usuarioId];
// Atualizar configurações
const configParticipantes = chamada.configuracoes?.participantesConfig || [];
const novaConfig = [...configParticipantes, {
usuarioId: args.usuarioId,
audioHabilitado: chamada.configuracoes?.audioHabilitado ?? true,
videoHabilitado: chamada.configuracoes?.videoHabilitado ?? (chamada.tipo === 'video'),
forcadoPeloAnfitriao: false
}];
const novaConfig = [
...configParticipantes,
{
usuarioId: args.usuarioId,
audioHabilitado: chamada.configuracoes?.audioHabilitado ?? true,
videoHabilitado: chamada.configuracoes?.videoHabilitado ?? chamada.tipo === 'video',
forcadoPeloAnfitriao: false
}
];
await ctx.db.patch(args.chamadaId, {
participantes: novosParticipantes,
@@ -333,7 +340,7 @@ export const removerParticipante = mutation({
// Atualizar participantes
const novosParticipantes = chamada.participantes.filter((id) => id !== args.usuarioId);
// Atualizar configurações
const configParticipantes = chamada.configuracoes?.participantesConfig || [];
const novaConfig = configParticipantes.filter((config) => config.usuarioId !== args.usuarioId);
@@ -388,17 +395,32 @@ export const toggleAudioVideoParticipante = mutation({
// Adicionar configuração se não existir
configParticipantes.push({
usuarioId: args.participanteId,
audioHabilitado: args.tipo === 'audio' ? args.habilitado : (chamada.configuracoes?.audioHabilitado ?? true),
videoHabilitado: args.tipo === 'video' ? args.habilitado : (chamada.configuracoes?.videoHabilitado ?? (chamada.tipo === 'video')),
audioHabilitado:
args.tipo === 'audio'
? args.habilitado
: (chamada.configuracoes?.audioHabilitado ?? true),
videoHabilitado:
args.tipo === 'video'
? args.habilitado
: (chamada.configuracoes?.videoHabilitado ?? chamada.tipo === 'video'),
forcadoPeloAnfitriao: !ehOProprioParticipante && args.habilitado === false
});
} else {
// Atualizar configuração existente
configParticipantes[participanteIndex] = {
...configParticipantes[participanteIndex],
audioHabilitado: args.tipo === 'audio' ? args.habilitado : configParticipantes[participanteIndex].audioHabilitado,
videoHabilitado: args.tipo === 'video' ? args.habilitado : configParticipantes[participanteIndex].videoHabilitado,
forcadoPeloAnfitriao: !ehOProprioParticipante && !args.habilitado ? true : configParticipantes[participanteIndex].forcadoPeloAnfitriao
audioHabilitado:
args.tipo === 'audio'
? args.habilitado
: configParticipantes[participanteIndex].audioHabilitado,
videoHabilitado:
args.tipo === 'video'
? args.habilitado
: configParticipantes[participanteIndex].videoHabilitado,
forcadoPeloAnfitriao:
!ehOProprioParticipante && !args.habilitado
? true
: configParticipantes[participanteIndex].forcadoPeloAnfitriao
};
}
@@ -509,10 +531,7 @@ export const obterChamadaAtiva = query({
.query('chamadas')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId))
.filter((q) =>
q.or(
q.eq(q.field('status'), 'aguardando'),
q.eq(q.field('status'), 'em_andamento')
)
q.or(q.eq(q.field('status'), 'aguardando'), q.eq(q.field('status'), 'em_andamento'))
)
.collect();
@@ -589,5 +608,3 @@ export const obterChamada = query({
return chamada;
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,229 +1,235 @@
import { v } from "convex/values";
import { mutation, query, action, internalMutation } from "./_generated/server";
import { encryptSMTPPassword } from "./auth/utils";
import { registrarAtividade } from "./logsAtividades";
import { api, internal } from "./_generated/api";
import { v } from 'convex/values';
import { mutation, query, action, internalMutation } from './_generated/server';
import { encryptSMTPPassword } from './auth/utils';
import { registrarAtividade } from './logsAtividades';
import { api, internal } from './_generated/api';
/**
* Obter configuração de email ativa (senha mascarada)
*/
export const obterConfigEmail = query({
args: {},
handler: async (ctx) => {
const config = await ctx.db
.query("configuracaoEmail")
.withIndex("by_ativo", (q) => q.eq("ativo", true))
.first();
args: {},
handler: async (ctx) => {
const config = await ctx.db
.query('configuracaoEmail')
.withIndex('by_ativo', (q) => q.eq('ativo', true))
.first();
if (!config) {
return null;
}
if (!config) {
return null;
}
// Retornar config com senha mascarada
return {
_id: config._id,
servidor: config.servidor,
porta: config.porta,
usuario: config.usuario,
senhaHash: "********", // Mascarar senha
emailRemetente: config.emailRemetente,
nomeRemetente: config.nomeRemetente,
usarSSL: config.usarSSL,
usarTLS: config.usarTLS,
ativo: config.ativo,
testadoEm: config.testadoEm,
atualizadoEm: config.atualizadoEm,
};
},
// Retornar config com senha mascarada
return {
_id: config._id,
servidor: config.servidor,
porta: config.porta,
usuario: config.usuario,
senhaHash: '********', // Mascarar senha
emailRemetente: config.emailRemetente,
nomeRemetente: config.nomeRemetente,
usarSSL: config.usarSSL,
usarTLS: config.usarTLS,
ativo: config.ativo,
testadoEm: config.testadoEm,
atualizadoEm: config.atualizadoEm
};
}
});
/**
* Salvar configuração de email (apenas TI_MASTER)
*/
export const salvarConfigEmail = mutation({
args: {
servidor: v.string(),
porta: v.number(),
usuario: v.string(),
senha: v.string(),
emailRemetente: v.string(),
nomeRemetente: v.string(),
usarSSL: v.boolean(),
usarTLS: v.boolean(),
configuradoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true), configId: v.id("configuracaoEmail") }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
// Validar email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(args.emailRemetente)) {
return { sucesso: false as const, erro: "Email remetente inválido" };
}
args: {
servidor: v.string(),
porta: v.number(),
usuario: v.string(),
senha: v.string(),
emailRemetente: v.string(),
nomeRemetente: v.string(),
usarSSL: v.boolean(),
usarTLS: v.boolean(),
configuradoPorId: v.id('usuarios')
},
returns: v.union(
v.object({ sucesso: v.literal(true), configId: v.id('configuracaoEmail') }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
// Validar email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(args.emailRemetente)) {
return { sucesso: false as const, erro: 'Email remetente inválido' };
}
// Validar porta
if (args.porta < 1 || args.porta > 65535) {
return { sucesso: false as const, erro: "Porta deve ser um número entre 1 e 65535" };
}
// Validar porta
if (args.porta < 1 || args.porta > 65535) {
return { sucesso: false as const, erro: 'Porta deve ser um número entre 1 e 65535' };
}
// Buscar config ativa anterior para manter senha se não fornecida
const configAtiva = await ctx.db
.query("configuracaoEmail")
.withIndex("by_ativo", (q) => q.eq("ativo", true))
.first();
// Buscar config ativa anterior para manter senha se não fornecida
const configAtiva = await ctx.db
.query('configuracaoEmail')
.withIndex('by_ativo', (q) => q.eq('ativo', true))
.first();
// Determinar senhaHash: usar nova senha se fornecida, senão manter a atual
let senhaHash: string;
if (args.senha && args.senha.trim().length > 0) {
// Nova senha fornecida, criptografar usando criptografia reversível (AES)
senhaHash = await encryptSMTPPassword(args.senha);
} else if (configAtiva) {
// Senha não fornecida, manter a atual (já criptografada)
senhaHash = configAtiva.senhaHash;
} else {
// Sem senha e sem config existente - erro
return { sucesso: false as const, erro: "Senha é obrigatória para nova configuração" };
}
// Determinar senhaHash: usar nova senha se fornecida, senão manter a atual
let senhaHash: string;
if (args.senha && args.senha.trim().length > 0) {
// Nova senha fornecida, criptografar usando criptografia reversível (AES)
senhaHash = await encryptSMTPPassword(args.senha);
} else if (configAtiva) {
// Senha não fornecida, manter a atual (já criptografada)
senhaHash = configAtiva.senhaHash;
} else {
// Sem senha e sem config existente - erro
return { sucesso: false as const, erro: 'Senha é obrigatória para nova configuração' };
}
// Desativar config anterior
const configsAntigas = await ctx.db
.query("configuracaoEmail")
.withIndex("by_ativo", (q) => q.eq("ativo", true))
.collect();
// Desativar config anterior
const configsAntigas = await ctx.db
.query('configuracaoEmail')
.withIndex('by_ativo', (q) => q.eq('ativo', true))
.collect();
for (const config of configsAntigas) {
await ctx.db.patch(config._id, { ativo: false });
}
for (const config of configsAntigas) {
await ctx.db.patch(config._id, { ativo: false });
}
// Criar nova config
const configId = await ctx.db.insert("configuracaoEmail", {
servidor: args.servidor,
porta: args.porta,
usuario: args.usuario,
senhaHash,
emailRemetente: args.emailRemetente,
nomeRemetente: args.nomeRemetente,
usarSSL: args.usarSSL,
usarTLS: args.usarTLS,
ativo: true,
configuradoPor: args.configuradoPorId,
atualizadoEm: Date.now(),
});
// Criar nova config
const configId = await ctx.db.insert('configuracaoEmail', {
servidor: args.servidor,
porta: args.porta,
usuario: args.usuario,
senhaHash,
emailRemetente: args.emailRemetente,
nomeRemetente: args.nomeRemetente,
usarSSL: args.usarSSL,
usarTLS: args.usarTLS,
ativo: true,
configuradoPor: args.configuradoPorId,
atualizadoEm: Date.now()
});
// Log de atividade
await registrarAtividade(
ctx,
args.configuradoPorId,
"configurar",
"email",
JSON.stringify({ servidor: args.servidor, porta: args.porta }),
configId
);
// Log de atividade
await registrarAtividade(
ctx,
args.configuradoPorId,
'configurar',
'email',
JSON.stringify({ servidor: args.servidor, porta: args.porta }),
configId
);
return { sucesso: true as const, configId };
},
return { sucesso: true as const, configId };
}
});
/**
* Mutation interna para atualizar testadoEm
*/
export const atualizarTestadoEm = internalMutation({
args: {
configId: v.id("configuracaoEmail"),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.configId, {
testadoEm: Date.now(),
});
return null;
},
args: {
configId: v.id('configuracaoEmail')
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.configId, {
testadoEm: Date.now()
});
return null;
}
});
/**
* Testar conexão SMTP (action que chama action real)
*/
export const testarConexaoSMTP = action({
args: {
servidor: v.string(),
porta: v.number(),
usuario: v.string(),
senha: v.string(),
usarSSL: v.boolean(),
usarTLS: v.boolean(),
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args): Promise<{ sucesso: true } | { sucesso: false; erro: string }> => {
// Validações básicas
if (!args.servidor || args.servidor.trim().length === 0) {
return { sucesso: false as const, erro: "Servidor SMTP não pode estar vazio" };
}
args: {
servidor: v.string(),
porta: v.number(),
usuario: v.string(),
senha: v.string(),
usarSSL: v.boolean(),
usarTLS: v.boolean()
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args): Promise<{ sucesso: true } | { sucesso: false; erro: string }> => {
// Validações básicas
if (!args.servidor || args.servidor.trim().length === 0) {
return { sucesso: false as const, erro: 'Servidor SMTP não pode estar vazio' };
}
if (!args.porta || args.porta < 1 || args.porta > 65535) {
return { sucesso: false as const, erro: "Porta inválida. Deve ser entre 1 e 65535" };
}
if (!args.porta || args.porta < 1 || args.porta > 65535) {
return { sucesso: false as const, erro: 'Porta inválida. Deve ser entre 1 e 65535' };
}
if (!args.usuario || args.usuario.trim().length === 0) {
return { sucesso: false as const, erro: "Usuário não pode estar vazio" };
}
if (!args.usuario || args.usuario.trim().length === 0) {
return { sucesso: false as const, erro: 'Usuário não pode estar vazio' };
}
if (!args.senha || args.senha.trim().length === 0) {
return { sucesso: false as const, erro: "Senha não pode estar vazia" };
}
if (!args.senha || args.senha.trim().length === 0) {
return { sucesso: false as const, erro: 'Senha não pode estar vazia' };
}
// Validação de SSL/TLS mutuamente exclusivos
if (args.usarSSL && args.usarTLS) {
return { sucesso: false as const, erro: "SSL e TLS não podem estar habilitados simultaneamente" };
}
// Validação de SSL/TLS mutuamente exclusivos
if (args.usarSSL && args.usarTLS) {
return {
sucesso: false as const,
erro: 'SSL e TLS não podem estar habilitados simultaneamente'
};
}
// Chamar action de teste real (que usa nodemailer)
try {
const resultado: { sucesso: true } | { sucesso: false; erro: string } = await ctx.runAction(api.actions.smtp.testarConexao, {
servidor: args.servidor,
porta: args.porta,
usuario: args.usuario,
senha: args.senha,
usarSSL: args.usarSSL,
usarTLS: args.usarTLS,
});
// Chamar action de teste real (que usa nodemailer)
try {
const resultado: { sucesso: true } | { sucesso: false; erro: string } = await ctx.runAction(
api.actions.smtp.testarConexao,
{
servidor: args.servidor,
porta: args.porta,
usuario: args.usuario,
senha: args.senha,
usarSSL: args.usarSSL,
usarTLS: args.usarTLS
}
);
// Se o teste foi bem-sucedido e há uma config ativa, atualizar testadoEm
if (resultado.sucesso) {
const configAtiva = await ctx.runQuery(api.configuracaoEmail.obterConfigEmail, {});
if (configAtiva) {
await ctx.runMutation(internal.configuracaoEmail.atualizarTestadoEm, {
configId: configAtiva._id,
});
}
}
// Se o teste foi bem-sucedido e há uma config ativa, atualizar testadoEm
if (resultado.sucesso) {
const configAtiva = await ctx.runQuery(api.configuracaoEmail.obterConfigEmail, {});
return resultado;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
sucesso: false as const,
erro: errorMessage || "Erro ao conectar com o servidor SMTP"
};
}
},
if (configAtiva) {
await ctx.runMutation(internal.configuracaoEmail.atualizarTestadoEm, {
configId: configAtiva._id
});
}
}
return resultado;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
sucesso: false as const,
erro: errorMessage || 'Erro ao conectar com o servidor SMTP'
};
}
}
});
/**
* Marcar que a configuração foi testada com sucesso
*/
export const marcarConfigTestada = mutation({
args: {
configId: v.id("configuracaoEmail"),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.configId, {
testadoEm: Date.now(),
});
},
args: {
configId: v.id('configuracaoEmail')
},
handler: async (ctx, args) => {
await ctx.db.patch(args.configId, {
testadoEm: Date.now()
});
}
});

View File

@@ -1,7 +1,7 @@
import { v } from "convex/values";
import { mutation, query, action, internalMutation } from "./_generated/server";
import { registrarAtividade } from "./logsAtividades";
import { api, internal } from "./_generated/api";
import { v } from 'convex/values';
import { mutation, query, action, internalMutation } from './_generated/server';
import { registrarAtividade } from './logsAtividades';
import { api, internal } from './_generated/api';
/**
* Obter configuração de Jitsi ativa
@@ -10,8 +10,8 @@ export const obterConfigJitsi = query({
args: {},
handler: async (ctx) => {
const config = await ctx.db
.query("configuracaoJitsi")
.withIndex("by_ativo", (q) => q.eq("ativo", true))
.query('configuracaoJitsi')
.withIndex('by_ativo', (q) => q.eq('ativo', true))
.first();
if (!config) {
@@ -27,12 +27,11 @@ export const obterConfigJitsi = query({
acceptSelfSignedCert: config.acceptSelfSignedCert ?? false, // Default para false se não existir
ativo: config.ativo,
testadoEm: config.testadoEm,
atualizadoEm: config.atualizadoEm,
atualizadoEm: config.atualizadoEm
};
},
}
});
/**
* Salvar configuração de Jitsi (apenas TI_MASTER)
*/
@@ -43,26 +42,26 @@ export const salvarConfigJitsi = mutation({
roomPrefix: v.string(),
useHttps: v.boolean(),
acceptSelfSignedCert: v.boolean(),
configuradoPorId: v.id("usuarios"),
configuradoPorId: v.id('usuarios')
},
returns: v.union(
v.object({ sucesso: v.literal(true), configId: v.id("configuracaoJitsi") }),
v.object({ sucesso: v.literal(true), configId: v.id('configuracaoJitsi') }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
// Validar domínio (deve ser não vazio)
if (!args.domain || args.domain.trim().length === 0) {
return { sucesso: false as const, erro: "Domínio não pode estar vazio" };
return { sucesso: false as const, erro: 'Domínio não pode estar vazio' };
}
// Validar appId (deve ser não vazio)
if (!args.appId || args.appId.trim().length === 0) {
return { sucesso: false as const, erro: "App ID não pode estar vazio" };
return { sucesso: false as const, erro: 'App ID não pode estar vazio' };
}
// Validar roomPrefix (deve ser não vazio e alfanumérico)
if (!args.roomPrefix || args.roomPrefix.trim().length === 0) {
return { sucesso: false as const, erro: "Prefixo de sala não pode estar vazio" };
return { sucesso: false as const, erro: 'Prefixo de sala não pode estar vazio' };
}
// Validar formato do roomPrefix (apenas letras, números e hífens)
@@ -70,14 +69,14 @@ export const salvarConfigJitsi = mutation({
if (!roomPrefixRegex.test(args.roomPrefix.trim())) {
return {
sucesso: false as const,
erro: "Prefixo de sala deve conter apenas letras, números e hífens",
erro: 'Prefixo de sala deve conter apenas letras, números e hífens'
};
}
// Desativar config anterior
const configsAntigas = await ctx.db
.query("configuracaoJitsi")
.withIndex("by_ativo", (q) => q.eq("ativo", true))
.query('configuracaoJitsi')
.withIndex('by_ativo', (q) => q.eq('ativo', true))
.collect();
for (const config of configsAntigas) {
@@ -85,7 +84,7 @@ export const salvarConfigJitsi = mutation({
}
// Criar nova config
const configId = await ctx.db.insert("configuracaoJitsi", {
const configId = await ctx.db.insert('configuracaoJitsi', {
domain: args.domain.trim(),
appId: args.appId.trim(),
roomPrefix: args.roomPrefix.trim(),
@@ -93,21 +92,21 @@ export const salvarConfigJitsi = mutation({
acceptSelfSignedCert: args.acceptSelfSignedCert,
ativo: true,
configuradoPor: args.configuradoPorId,
atualizadoEm: Date.now(),
atualizadoEm: Date.now()
});
// Log de atividade
await registrarAtividade(
ctx,
args.configuradoPorId,
"configurar",
"jitsi",
'configurar',
'jitsi',
JSON.stringify({ domain: args.domain, appId: args.appId }),
configId
);
return { sucesso: true as const, configId };
},
}
});
/**
@@ -115,15 +114,15 @@ export const salvarConfigJitsi = mutation({
*/
export const atualizarTestadoEm = internalMutation({
args: {
configId: v.id("configuracaoJitsi"),
configId: v.id('configuracaoJitsi')
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.configId, {
testadoEm: Date.now(),
testadoEm: Date.now()
});
return null;
},
}
});
/**
@@ -133,22 +132,25 @@ export const testarConexaoJitsi = action({
args: {
domain: v.string(),
useHttps: v.boolean(),
acceptSelfSignedCert: v.optional(v.boolean()),
acceptSelfSignedCert: v.optional(v.boolean())
},
returns: v.union(
v.object({ sucesso: v.literal(true), aviso: v.optional(v.string()) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args): Promise<{ sucesso: true; aviso?: string } | { sucesso: false; erro: string }> => {
handler: async (
ctx,
args
): Promise<{ sucesso: true; aviso?: string } | { sucesso: false; erro: string }> => {
// Validações básicas
if (!args.domain || args.domain.trim().length === 0) {
return { sucesso: false as const, erro: "Domínio não pode estar vazio" };
return { sucesso: false as const, erro: 'Domínio não pode estar vazio' };
}
try {
const protocol = args.useHttps ? "https" : "http";
const protocol = args.useHttps ? 'https' : 'http';
// Extrair host e porta do domain
const [host, portStr] = args.domain.split(":");
const [host, portStr] = args.domain.split(':');
const port = portStr ? parseInt(portStr, 10) : args.useHttps ? 443 : 80;
const url = `${protocol}://${host}:${port}/http-bind`;
@@ -159,11 +161,11 @@ export const testarConexaoJitsi = action({
try {
const response = await fetch(url, {
method: "GET",
method: 'GET',
signal: controller.signal,
headers: {
"Content-Type": "application/xml",
},
'Content-Type': 'application/xml'
}
});
clearTimeout(timeoutId);
@@ -176,7 +178,7 @@ export const testarConexaoJitsi = action({
if (configAtiva) {
await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
configId: configAtiva._id,
configId: configAtiva._id
});
}
@@ -184,30 +186,29 @@ export const testarConexaoJitsi = action({
} else {
return {
sucesso: false as const,
erro: `Servidor retornou status ${response.status}`,
erro: `Servidor retornou status ${response.status}`
};
}
} catch (fetchError: unknown) {
clearTimeout(timeoutId);
const errorMessage =
fetchError instanceof Error ? fetchError.message : String(fetchError);
const errorMessage = fetchError instanceof Error ? fetchError.message : String(fetchError);
// Se for erro de timeout
if (errorMessage.includes("aborted") || errorMessage.includes("timeout")) {
if (errorMessage.includes('aborted') || errorMessage.includes('timeout')) {
return {
sucesso: false as const,
erro: "Timeout: Servidor não respondeu em 5 segundos",
erro: 'Timeout: Servidor não respondeu em 5 segundos'
};
}
// Verificar se é erro de certificado SSL autoassinado
const isSSLError =
errorMessage.includes("CERTIFICATE_VERIFY_FAILED") ||
errorMessage.includes("self signed certificate") ||
errorMessage.includes("self-signed certificate") ||
errorMessage.includes("certificate") ||
errorMessage.includes("SSL") ||
errorMessage.includes("certificate verify failed");
const isSSLError =
errorMessage.includes('CERTIFICATE_VERIFY_FAILED') ||
errorMessage.includes('self signed certificate') ||
errorMessage.includes('self-signed certificate') ||
errorMessage.includes('certificate') ||
errorMessage.includes('SSL') ||
errorMessage.includes('certificate verify failed');
// Se for erro de certificado e aceitar autoassinado está configurado
if (isSSLError && args.acceptSelfSignedCert) {
@@ -218,26 +219,27 @@ export const testarConexaoJitsi = action({
if (configAtiva) {
await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
configId: configAtiva._id,
configId: configAtiva._id
});
}
return {
return {
sucesso: true as const,
aviso: "Servidor acessível com certificado autoassinado. No navegador, você precisará aceitar o certificado manualmente na primeira conexão."
aviso:
'Servidor acessível com certificado autoassinado. No navegador, você precisará aceitar o certificado manualmente na primeira conexão.'
};
}
// Para servidores Jitsi, pode ser normal receber erro 405 (Method Not Allowed)
// para GET em /http-bind, pois esse endpoint espera POST (BOSH)
// Isso indica que o servidor está acessível, apenas não aceita GET
if (errorMessage.includes("405") || errorMessage.includes("Method Not Allowed")) {
if (errorMessage.includes('405') || errorMessage.includes('Method Not Allowed')) {
// Se o teste foi bem-sucedido e há uma config ativa, atualizar testadoEm
const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
if (configAtiva) {
await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
configId: configAtiva._id,
configId: configAtiva._id
});
}
@@ -248,23 +250,23 @@ export const testarConexaoJitsi = action({
if (isSSLError) {
return {
sucesso: false as const,
erro: `Erro de certificado SSL: O servidor está usando um certificado não confiável (provavelmente autoassinado). Para desenvolvimento local, habilite "Aceitar Certificados Autoassinados" nas configurações de segurança. Em produção, use um certificado válido (ex: Let's Encrypt).`,
erro: `Erro de certificado SSL: O servidor está usando um certificado não confiável (provavelmente autoassinado). Para desenvolvimento local, habilite "Aceitar Certificados Autoassinados" nas configurações de segurança. Em produção, use um certificado válido (ex: Let's Encrypt).`
};
}
return {
sucesso: false as const,
erro: `Erro ao conectar: ${errorMessage}`,
erro: `Erro ao conectar: ${errorMessage}`
};
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
sucesso: false as const,
erro: errorMessage || "Erro ao conectar com o servidor Jitsi",
erro: errorMessage || 'Erro ao conectar com o servidor Jitsi'
};
}
},
}
});
/**
@@ -272,13 +274,13 @@ export const testarConexaoJitsi = action({
*/
export const marcarConfigTestada = mutation({
args: {
configId: v.id("configuracaoJitsi"),
configId: v.id('configuracaoJitsi')
},
handler: async (ctx, args) => {
await ctx.db.patch(args.configId, {
testadoEm: Date.now(),
testadoEm: Date.now()
});
},
}
});
/**
@@ -286,16 +288,15 @@ export const marcarConfigTestada = mutation({
*/
export const marcarConfiguradoNoServidor = internalMutation({
args: {
configId: v.id("configuracaoJitsi"),
configId: v.id('configuracaoJitsi')
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.configId, {
configuradoNoServidor: true,
configuradoNoServidorEm: Date.now(),
configuradoEm: Date.now(),
configuradoEm: Date.now()
});
return null;
},
}
});

View File

@@ -36,7 +36,7 @@ export const obterConfiguracao = query({
nomeSaida: 'Saída 2',
validarLocalizacao: true,
toleranciaDistanciaMetros: 100,
ativo: false,
ativo: false
};
}
@@ -48,9 +48,9 @@ export const obterConfiguracao = query({
nomeRetornoAlmoco: config.nomeRetornoAlmoco || 'Entrada 2',
nomeSaida: config.nomeSaida || 'Saída 2',
validarLocalizacao: config.validarLocalizacao ?? true,
toleranciaDistanciaMetros: config.toleranciaDistanciaMetros ?? 100,
toleranciaDistanciaMetros: config.toleranciaDistanciaMetros ?? 100
};
},
}
});
/**
@@ -68,7 +68,7 @@ export const salvarConfiguracao = mutation({
nomeRetornoAlmoco: v.optional(v.string()),
nomeSaida: v.optional(v.string()),
validarLocalizacao: v.optional(v.boolean()),
toleranciaDistanciaMetros: v.optional(v.number()),
toleranciaDistanciaMetros: v.optional(v.number())
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -101,7 +101,9 @@ export const salvarConfiguracao = mutation({
// Validar sequência lógica de horários
const [horaEntrada, minutoEntrada] = args.horarioEntrada.split(':').map(Number);
const [horaSaidaAlmoco, minutoSaidaAlmoco] = args.horarioSaidaAlmoco.split(':').map(Number);
const [horaRetornoAlmoco, minutoRetornoAlmoco] = args.horarioRetornoAlmoco.split(':').map(Number);
const [horaRetornoAlmoco, minutoRetornoAlmoco] = args.horarioRetornoAlmoco
.split(':')
.map(Number);
const [horaSaida, minutoSaida] = args.horarioSaida.split(':').map(Number);
const minutosEntrada = horaEntrada * 60 + minutoEntrada;
@@ -151,10 +153,9 @@ export const salvarConfiguracao = mutation({
toleranciaDistanciaMetros: args.toleranciaDistanciaMetros ?? 100,
ativo: true,
atualizadoPor: usuario._id as Id<'usuarios'>,
atualizadoEm: Date.now(),
atualizadoEm: Date.now()
});
return { configId };
},
}
});

View File

@@ -24,14 +24,13 @@ export const obterConfiguracao = query({
args: {},
handler: async (ctx): Promise<ConfiguracaoRelogioRetorno> => {
// Buscar todas as configurações e pegar a mais recente (por atualizadoEm)
const configs = await ctx.db
.query('configuracaoRelogio')
.collect();
const configs = await ctx.db.query('configuracaoRelogio').collect();
// Pegar a configuração mais recente (ordenar por atualizadoEm desc)
const config = configs.length > 0
? configs.sort((a, b) => (b.atualizadoEm || 0) - (a.atualizadoEm || 0))[0]
: null;
const config =
configs.length > 0
? configs.sort((a, b) => (b.atualizadoEm || 0) - (a.atualizadoEm || 0))[0]
: null;
if (!config) {
// Retornar configuração padrão (GMT-3 para Brasília)
@@ -42,7 +41,7 @@ export const obterConfiguracao = query({
fallbackParaPC: true,
ultimaSincronizacao: null,
offsetSegundos: null,
gmtOffset: -3, // GMT-3 para Brasília
gmtOffset: -3 // GMT-3 para Brasília
};
}
@@ -50,9 +49,9 @@ export const obterConfiguracao = query({
...config,
ultimaSincronizacao: config.ultimaSincronizacao ?? null, // Converter undefined para null
offsetSegundos: config.offsetSegundos ?? null, // Converter undefined para null
gmtOffset: config.gmtOffset ?? -3, // Padrão GMT-3 para Brasília se não configurado
gmtOffset: config.gmtOffset ?? -3 // Padrão GMT-3 para Brasília se não configurado
};
},
}
});
/**
@@ -62,14 +61,13 @@ export const obterConfiguracaoInternal = internalQuery({
args: {},
handler: async (ctx): Promise<ConfiguracaoRelogioRetorno> => {
// Buscar todas as configurações e pegar a mais recente (por atualizadoEm)
const configs = await ctx.db
.query('configuracaoRelogio')
.collect();
const configs = await ctx.db.query('configuracaoRelogio').collect();
// Pegar a configuração mais recente (ordenar por atualizadoEm desc)
const config = configs.length > 0
? configs.sort((a, b) => (b.atualizadoEm || 0) - (a.atualizadoEm || 0))[0]
: null;
const config =
configs.length > 0
? configs.sort((a, b) => (b.atualizadoEm || 0) - (a.atualizadoEm || 0))[0]
: null;
if (!config) {
// Retornar configuração padrão (GMT-3 para Brasília)
@@ -80,7 +78,7 @@ export const obterConfiguracaoInternal = internalQuery({
fallbackParaPC: true,
ultimaSincronizacao: null,
offsetSegundos: null,
gmtOffset: -3, // GMT-3 para Brasília
gmtOffset: -3 // GMT-3 para Brasília
};
}
@@ -88,9 +86,9 @@ export const obterConfiguracaoInternal = internalQuery({
...config,
ultimaSincronizacao: config.ultimaSincronizacao ?? null, // Converter undefined para null
offsetSegundos: config.offsetSegundos ?? null, // Converter undefined para null
gmtOffset: config.gmtOffset ?? -3, // Padrão GMT-3 para Brasília se não configurado
gmtOffset: config.gmtOffset ?? -3 // Padrão GMT-3 para Brasília se não configurado
};
},
}
});
/**
@@ -102,7 +100,7 @@ export const salvarConfiguracao = mutation({
portaNTP: v.optional(v.number()),
usarServidorExterno: v.boolean(),
fallbackParaPC: v.boolean(),
gmtOffset: v.optional(v.number()),
gmtOffset: v.optional(v.number())
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -123,13 +121,12 @@ export const salvarConfiguracao = mutation({
}
// Buscar configuração existente (pegar a mais recente)
const configs = await ctx.db
.query('configuracaoRelogio')
.collect();
const configExistente = configs.length > 0
? configs.sort((a, b) => (b.atualizadoEm || 0) - (a.atualizadoEm || 0))[0]
: null;
const configs = await ctx.db.query('configuracaoRelogio').collect();
const configExistente =
configs.length > 0
? configs.sort((a, b) => (b.atualizadoEm || 0) - (a.atualizadoEm || 0))[0]
: null;
if (configExistente) {
// Atualizar configuração existente
@@ -140,7 +137,7 @@ export const salvarConfiguracao = mutation({
fallbackParaPC: args.fallbackParaPC,
gmtOffset: args.gmtOffset ?? -3, // Padrão GMT-3 para Brasília
atualizadoPor: usuario._id as Id<'usuarios'>,
atualizadoEm: Date.now(),
atualizadoEm: Date.now()
});
return { configId: configExistente._id };
} else {
@@ -152,11 +149,11 @@ export const salvarConfiguracao = mutation({
fallbackParaPC: args.fallbackParaPC,
gmtOffset: args.gmtOffset ?? -3, // Padrão GMT-3 para Brasília
atualizadoPor: usuario._id as Id<'usuarios'>,
atualizadoEm: Date.now(),
atualizadoEm: Date.now()
});
return { configId };
}
},
}
});
/**
@@ -167,9 +164,9 @@ export const obterTempoServidor = query({
handler: async () => {
return {
timestamp: Date.now(),
data: new Date().toISOString(),
data: new Date().toISOString()
};
},
}
});
/**
@@ -191,14 +188,17 @@ export const sincronizarTempo = action({
args: {},
handler: async (ctx): Promise<SincronizacaoRetorno> => {
// Buscar configuração usando query interna para evitar referência circular
const config: ConfiguracaoRelogioRetorno = await ctx.runQuery(internal.configuracaoRelogio.obterConfiguracaoInternal, {});
const config: ConfiguracaoRelogioRetorno = await ctx.runQuery(
internal.configuracaoRelogio.obterConfiguracaoInternal,
{}
);
if (!config.usarServidorExterno) {
return {
sucesso: true,
timestamp: Date.now(),
usandoServidorExterno: false,
offsetSegundos: 0,
offsetSegundos: 0
};
}
@@ -208,7 +208,7 @@ export const sincronizarTempo = action({
try {
const servidorNTP = config.servidorNTP || 'pool.ntp.org';
let serverTime: number;
// Se o servidor configurado for uma URL HTTP/HTTPS, tentar usar diretamente
if (servidorNTP.startsWith('http://') || servidorNTP.startsWith('https://')) {
try {
@@ -216,7 +216,11 @@ export const sincronizarTempo = action({
if (!response.ok) {
throw new Error('Falha ao obter tempo do servidor configurado');
}
const data = (await response.json()) as { unixtime?: number; unixTime?: number; unixtimestamp?: number };
const data = (await response.json()) as {
unixtime?: number;
unixTime?: number;
unixtimestamp?: number;
};
// Tentar diferentes formatos de resposta
if (data.unixtime) {
serverTime = data.unixtime * 1000; // Converter segundos para milissegundos
@@ -261,7 +265,7 @@ export const sincronizarTempo = action({
if (configExistente) {
await ctx.runMutation(internal.configuracaoRelogio.atualizarSincronizacao, {
configId: configExistente._id,
offsetSegundos,
offsetSegundos
});
}
@@ -269,7 +273,7 @@ export const sincronizarTempo = action({
sucesso: true,
timestamp: serverTime, // Retorna UTC (sem GMT offset aplicado)
usandoServidorExterno: true,
offsetSegundos,
offsetSegundos
};
} catch (error) {
// Sempre usar fallback como última opção, mesmo se desabilitado
@@ -277,18 +281,18 @@ export const sincronizarTempo = action({
const aviso: string = config.fallbackParaPC
? 'Falha ao sincronizar com servidor externo, usando relógio do PC'
: 'Falha ao sincronizar com servidor externo. Fallback desabilitado, mas usando relógio do PC como última opção.';
console.warn('Erro ao sincronizar tempo com servidor externo:', error);
return {
sucesso: true,
timestamp: Date.now(),
usandoServidorExterno: false,
offsetSegundos: 0,
aviso,
aviso
};
}
},
}
});
/**
@@ -298,7 +302,7 @@ export const listarConfiguracoes = internalQuery({
args: {},
handler: async (ctx) => {
return await ctx.db.query('configuracaoRelogio').collect();
},
}
});
/**
@@ -307,13 +311,12 @@ export const listarConfiguracoes = internalQuery({
export const atualizarSincronizacao = internalMutation({
args: {
configId: v.id('configuracaoRelogio'),
offsetSegundos: v.number(),
offsetSegundos: v.number()
},
handler: async (ctx, args) => {
await ctx.db.patch(args.configId, {
ultimaSincronizacao: Date.now(),
offsetSegundos: args.offsetSegundos,
offsetSegundos: args.offsetSegundos
});
},
}
});

View File

@@ -1,6 +1,6 @@
import { defineApp } from "convex/server";
import betterAuth from "@convex-dev/better-auth/convex.config";
import rateLimiter from "@convex-dev/rate-limiter/convex.config";
import { defineApp } from 'convex/server';
import betterAuth from '@convex-dev/better-auth/convex.config';
import rateLimiter from '@convex-dev/rate-limiter/convex.config';
const app = defineApp();
app.use(betterAuth);

View File

@@ -1,58 +1,53 @@
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
import { cronJobs } from 'convex/server';
import { internal } from './_generated/api';
const crons = cronJobs();
// Enviar mensagens agendadas a cada minuto
crons.interval(
"enviar-mensagens-agendadas",
{ minutes: 1 },
internal.chat.enviarMensagensAgendadas
'enviar-mensagens-agendadas',
{ minutes: 1 },
internal.chat.enviarMensagensAgendadas
);
// Processar fila de emails (incluindo agendados) a cada minuto
crons.interval(
"processar-fila-emails",
{ minutes: 1 },
internal.email.processarFilaEmails
);
crons.interval('processar-fila-emails', { minutes: 1 }, internal.email.processarFilaEmails);
// Limpar indicadores de digitação antigos (>10s) a cada minuto
crons.interval(
"limpar-indicadores-digitacao",
{ minutes: 1 },
internal.chat.limparIndicadoresDigitacao
'limpar-indicadores-digitacao',
{ minutes: 1 },
internal.chat.limparIndicadoresDigitacao
);
// Atualizar status de férias dos funcionários diariamente
crons.interval(
"atualizar-status-ferias",
{ hours: 24 },
internal.ferias.atualizarStatusTodosFuncionarios,
{}
'atualizar-status-ferias',
{ hours: 24 },
internal.ferias.atualizarStatusTodosFuncionarios,
{}
);
crons.interval(
"expirar-bloqueios-ip-automaticos",
{ minutes: 5 },
internal.security.expirarBloqueiosIpAutomaticos,
{}
'expirar-bloqueios-ip-automaticos',
{ minutes: 5 },
internal.security.expirarBloqueiosIpAutomaticos,
{}
);
crons.interval(
"sincronizar-threat-intel",
{ hours: 2 },
internal.security.atualizarThreatIntelFeedsInternal,
{}
'sincronizar-threat-intel',
{ hours: 2 },
internal.security.atualizarThreatIntelFeedsInternal,
{}
);
// Monitorar logs de login e detectar brute force a cada 5 minutos
crons.interval(
"monitorar-logs-login-brute-force",
{ minutes: 5 },
internal.security.monitorarLogsLogin,
{}
'monitorar-logs-login-brute-force',
{ minutes: 5 },
internal.security.monitorarLogsLogin,
{}
);
export default crons;

View File

@@ -1,67 +1,64 @@
import { v } from "convex/values";
import { query, mutation } from "./_generated/server";
import { v } from 'convex/values';
import { query, mutation } from './_generated/server';
export const listarPorFuncionario = query({
args: {
funcionarioId: v.id("funcionarios"),
},
returns: v.array(
v.object({
_id: v.id("cursos"),
_creationTime: v.number(),
funcionarioId: v.id("funcionarios"),
descricao: v.string(),
data: v.string(),
certificadoId: v.optional(v.id("_storage")),
})
),
handler: async (ctx, args) => {
return await ctx.db
.query("cursos")
.withIndex("by_funcionario", (q) =>
q.eq("funcionarioId", args.funcionarioId)
)
.collect();
},
args: {
funcionarioId: v.id('funcionarios')
},
returns: v.array(
v.object({
_id: v.id('cursos'),
_creationTime: v.number(),
funcionarioId: v.id('funcionarios'),
descricao: v.string(),
data: v.string(),
certificadoId: v.optional(v.id('_storage'))
})
),
handler: async (ctx, args) => {
return await ctx.db
.query('cursos')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
.collect();
}
});
export const criar = mutation({
args: {
funcionarioId: v.id("funcionarios"),
descricao: v.string(),
data: v.string(),
certificadoId: v.optional(v.id("_storage")),
},
returns: v.id("cursos"),
handler: async (ctx, args) => {
const cursoId = await ctx.db.insert("cursos", args);
return cursoId;
},
args: {
funcionarioId: v.id('funcionarios'),
descricao: v.string(),
data: v.string(),
certificadoId: v.optional(v.id('_storage'))
},
returns: v.id('cursos'),
handler: async (ctx, args) => {
const cursoId = await ctx.db.insert('cursos', args);
return cursoId;
}
});
export const atualizar = mutation({
args: {
id: v.id("cursos"),
descricao: v.string(),
data: v.string(),
certificadoId: v.optional(v.id("_storage")),
},
returns: v.null(),
handler: async (ctx, args) => {
const { id, ...updates } = args;
await ctx.db.patch(id, updates);
return null;
},
args: {
id: v.id('cursos'),
descricao: v.string(),
data: v.string(),
certificadoId: v.optional(v.id('_storage'))
},
returns: v.null(),
handler: async (ctx, args) => {
const { id, ...updates } = args;
await ctx.db.patch(id, updates);
return null;
}
});
export const excluir = mutation({
args: {
id: v.id("cursos"),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.delete(args.id);
return null;
},
args: {
id: v.id('cursos')
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.delete(args.id);
return null;
}
});

View File

@@ -1,152 +1,144 @@
import { query } from "./_generated/server";
import { v } from "convex/values";
import { query } from './_generated/server';
import { v } from 'convex/values';
// Obter estatísticas gerais do sistema
export const getStats = query({
args: {},
returns: v.object({
totalFuncionarios: v.number(),
totalSimbolos: v.number(),
funcionariosAtivos: v.number(),
funcionariosDesligados: v.number(),
cargoComissionado: v.number(),
funcaoGratificada: v.number(),
}),
handler: async (ctx) => {
// Contar funcionários
const funcionarios = await ctx.db.query("funcionarios").collect();
const totalFuncionarios = funcionarios.length;
// Funcionários ativos (sem data de desligamento)
const funcionariosAtivos = funcionarios.filter(
(f) => !f.desligamentoData
).length;
// Funcionários desligados
const funcionariosDesligados = funcionarios.filter(
(f) => f.desligamentoData
).length;
args: {},
returns: v.object({
totalFuncionarios: v.number(),
totalSimbolos: v.number(),
funcionariosAtivos: v.number(),
funcionariosDesligados: v.number(),
cargoComissionado: v.number(),
funcaoGratificada: v.number()
}),
handler: async (ctx) => {
// Contar funcionários
const funcionarios = await ctx.db.query('funcionarios').collect();
const totalFuncionarios = funcionarios.length;
// Contar por tipo de símbolo
const cargoComissionado = funcionarios.filter(
(f) => f.simboloTipo === "cargo_comissionado"
).length;
const funcaoGratificada = funcionarios.filter(
(f) => f.simboloTipo === "funcao_gratificada"
).length;
// Funcionários ativos (sem data de desligamento)
const funcionariosAtivos = funcionarios.filter((f) => !f.desligamentoData).length;
// Contar símbolos
const simbolos = await ctx.db.query("simbolos").collect();
const totalSimbolos = simbolos.length;
// Funcionários desligados
const funcionariosDesligados = funcionarios.filter((f) => f.desligamentoData).length;
return {
totalFuncionarios,
totalSimbolos,
funcionariosAtivos,
funcionariosDesligados,
cargoComissionado,
funcaoGratificada,
};
},
// Contar por tipo de símbolo
const cargoComissionado = funcionarios.filter(
(f) => f.simboloTipo === 'cargo_comissionado'
).length;
const funcaoGratificada = funcionarios.filter(
(f) => f.simboloTipo === 'funcao_gratificada'
).length;
// Contar símbolos
const simbolos = await ctx.db.query('simbolos').collect();
const totalSimbolos = simbolos.length;
return {
totalFuncionarios,
totalSimbolos,
funcionariosAtivos,
funcionariosDesligados,
cargoComissionado,
funcaoGratificada
};
}
});
// Obter atividades recentes (últimas 24 horas)
export const getRecentActivity = query({
args: {},
returns: v.object({
funcionariosCadastrados24h: v.number(),
simbolosCadastrados24h: v.number(),
}),
handler: async (ctx) => {
const now = Date.now();
const last24h = now - 24 * 60 * 60 * 1000;
args: {},
returns: v.object({
funcionariosCadastrados24h: v.number(),
simbolosCadastrados24h: v.number()
}),
handler: async (ctx) => {
const now = Date.now();
const last24h = now - 24 * 60 * 60 * 1000;
// Funcionários cadastrados nas últimas 24h
const funcionarios = await ctx.db.query("funcionarios").collect();
const funcionariosCadastrados24h = funcionarios.filter(
(f) => f._creationTime >= last24h
).length;
// Funcionários cadastrados nas últimas 24h
const funcionarios = await ctx.db.query('funcionarios').collect();
const funcionariosCadastrados24h = funcionarios.filter(
(f) => f._creationTime >= last24h
).length;
// Símbolos cadastrados nas últimas 24h
const simbolos = await ctx.db.query('simbolos').collect();
const simbolosCadastrados24h = simbolos.filter((s) => s._creationTime >= last24h).length;
// Símbolos cadastrados nas últimas 24h
const simbolos = await ctx.db.query("simbolos").collect();
const simbolosCadastrados24h = simbolos.filter(
(s) => s._creationTime >= last24h
).length;
return {
funcionariosCadastrados24h,
simbolosCadastrados24h,
};
},
return {
funcionariosCadastrados24h,
simbolosCadastrados24h
};
}
});
// Obter distribuição de funcionários por cidade
export const getFuncionariosPorCidade = query({
args: {},
returns: v.array(
v.object({
cidade: v.string(),
quantidade: v.number(),
})
),
handler: async (ctx) => {
const funcionarios = await ctx.db.query("funcionarios").collect();
const cidadesMap: Record<string, number> = {};
for (const func of funcionarios) {
if (!func.desligamentoData) {
cidadesMap[func.cidade] = (cidadesMap[func.cidade] || 0) + 1;
}
}
args: {},
returns: v.array(
v.object({
cidade: v.string(),
quantidade: v.number()
})
),
handler: async (ctx) => {
const funcionarios = await ctx.db.query('funcionarios').collect();
const result = Object.entries(cidadesMap)
.map(([cidade, quantidade]) => ({ cidade, quantidade }))
.sort((a, b) => b.quantidade - a.quantidade)
.slice(0, 5); // Top 5 cidades
const cidadesMap: Record<string, number> = {};
return result;
},
for (const func of funcionarios) {
if (!func.desligamentoData) {
cidadesMap[func.cidade] = (cidadesMap[func.cidade] || 0) + 1;
}
}
const result = Object.entries(cidadesMap)
.map(([cidade, quantidade]) => ({ cidade, quantidade }))
.sort((a, b) => b.quantidade - a.quantidade)
.slice(0, 5); // Top 5 cidades
return result;
}
});
// Obter evolução de cadastros por mês
export const getEvolucaoCadastros = query({
args: {},
returns: v.array(
v.object({
mes: v.string(),
funcionarios: v.number(),
})
),
handler: async (ctx) => {
const funcionarios = await ctx.db.query("funcionarios").collect();
args: {},
returns: v.array(
v.object({
mes: v.string(),
funcionarios: v.number()
})
),
handler: async (ctx) => {
const funcionarios = await ctx.db.query('funcionarios').collect();
const now = new Date();
const meses: Array<{ mes: string; funcionarios: number }> = [];
const now = new Date();
const meses: Array<{ mes: string; funcionarios: number }> = [];
// Últimos 6 meses
for (let i = 5; i >= 0; i--) {
const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
const nextDate = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
const mesNome = date.toLocaleDateString("pt-BR", {
month: "short",
year: "2-digit",
});
// Últimos 6 meses
for (let i = 5; i >= 0; i--) {
const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
const nextDate = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
const funcCount = funcionarios.filter(
(f) => f._creationTime >= date.getTime() && f._creationTime < nextDate.getTime()
).length;
const mesNome = date.toLocaleDateString('pt-BR', {
month: 'short',
year: '2-digit'
});
meses.push({
mes: mesNome,
funcionarios: funcCount,
});
}
const funcCount = funcionarios.filter(
(f) => f._creationTime >= date.getTime() && f._creationTime < nextDate.getTime()
).length;
return meses;
},
meses.push({
mes: mesNome,
funcionarios: funcCount
});
}
return meses;
}
});

View File

@@ -1,202 +1,201 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { Id } from "./_generated/dataModel";
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
import { Id } from './_generated/dataModel';
// Mutation para fazer upload de arquivo e obter o storage ID
export const generateUploadUrl = mutation({
args: {},
returns: v.string(),
handler: async (ctx) => {
return await ctx.storage.generateUploadUrl();
},
args: {},
returns: v.string(),
handler: async (ctx) => {
return await ctx.storage.generateUploadUrl();
}
});
// Mutation para atualizar um campo de documento do funcionário
export const updateDocumento = mutation({
args: {
funcionarioId: v.id("funcionarios"),
campo: v.string(),
storageId: v.union(v.id("_storage"), v.null()),
},
returns: v.null(),
handler: async (ctx, args) => {
const funcionario = await ctx.db.get(args.funcionarioId);
if (!funcionario) {
throw new Error("Funcionário não encontrado");
}
args: {
funcionarioId: v.id('funcionarios'),
campo: v.string(),
storageId: v.union(v.id('_storage'), v.null())
},
returns: v.null(),
handler: async (ctx, args) => {
const funcionario = await ctx.db.get(args.funcionarioId);
if (!funcionario) {
throw new Error('Funcionário não encontrado');
}
// Atualizar o campo específico do documento
await ctx.db.patch(args.funcionarioId, {
[args.campo]: args.storageId,
});
// Atualizar o campo específico do documento
await ctx.db.patch(args.funcionarioId, {
[args.campo]: args.storageId
});
return null;
},
return null;
}
});
// Query para obter URLs de todos os documentos de um funcionário
export const getDocumentosUrls = query({
args: { funcionarioId: v.id("funcionarios") },
returns: v.object({
certidaoAntecedentesPF: v.union(v.string(), v.null()),
certidaoAntecedentesJFPE: v.union(v.string(), v.null()),
certidaoAntecedentesSDS: v.union(v.string(), v.null()),
certidaoAntecedentesTJPE: v.union(v.string(), v.null()),
certidaoImprobidade: v.union(v.string(), v.null()),
rgFrente: v.union(v.string(), v.null()),
rgVerso: v.union(v.string(), v.null()),
cpfFrente: v.union(v.string(), v.null()),
cpfVerso: v.union(v.string(), v.null()),
situacaoCadastralCPF: v.union(v.string(), v.null()),
tituloEleitorFrente: v.union(v.string(), v.null()),
tituloEleitorVerso: v.union(v.string(), v.null()),
comprovanteVotacao: v.union(v.string(), v.null()),
carteiraProfissionalFrente: v.union(v.string(), v.null()),
carteiraProfissionalVerso: v.union(v.string(), v.null()),
comprovantePIS: v.union(v.string(), v.null()),
certidaoRegistroCivil: v.union(v.string(), v.null()),
certidaoNascimentoDependentes: v.union(v.string(), v.null()),
cpfDependentes: v.union(v.string(), v.null()),
reservistaDoc: v.union(v.string(), v.null()),
comprovanteEscolaridade: v.union(v.string(), v.null()),
comprovanteResidencia: v.union(v.string(), v.null()),
comprovanteContaBradesco: v.union(v.string(), v.null()),
declaracaoAcumulacaoCargo: v.union(v.string(), v.null()),
declaracaoDependentesIR: v.union(v.string(), v.null()),
declaracaoIdoneidade: v.union(v.string(), v.null()),
termoNepotismo: v.union(v.string(), v.null()),
termoOpcaoRemuneracao: v.union(v.string(), v.null()),
}),
handler: async (ctx, args) => {
const funcionario = await ctx.db.get(args.funcionarioId);
if (!funcionario) {
throw new Error("Funcionário não encontrado");
}
args: { funcionarioId: v.id('funcionarios') },
returns: v.object({
certidaoAntecedentesPF: v.union(v.string(), v.null()),
certidaoAntecedentesJFPE: v.union(v.string(), v.null()),
certidaoAntecedentesSDS: v.union(v.string(), v.null()),
certidaoAntecedentesTJPE: v.union(v.string(), v.null()),
certidaoImprobidade: v.union(v.string(), v.null()),
rgFrente: v.union(v.string(), v.null()),
rgVerso: v.union(v.string(), v.null()),
cpfFrente: v.union(v.string(), v.null()),
cpfVerso: v.union(v.string(), v.null()),
situacaoCadastralCPF: v.union(v.string(), v.null()),
tituloEleitorFrente: v.union(v.string(), v.null()),
tituloEleitorVerso: v.union(v.string(), v.null()),
comprovanteVotacao: v.union(v.string(), v.null()),
carteiraProfissionalFrente: v.union(v.string(), v.null()),
carteiraProfissionalVerso: v.union(v.string(), v.null()),
comprovantePIS: v.union(v.string(), v.null()),
certidaoRegistroCivil: v.union(v.string(), v.null()),
certidaoNascimentoDependentes: v.union(v.string(), v.null()),
cpfDependentes: v.union(v.string(), v.null()),
reservistaDoc: v.union(v.string(), v.null()),
comprovanteEscolaridade: v.union(v.string(), v.null()),
comprovanteResidencia: v.union(v.string(), v.null()),
comprovanteContaBradesco: v.union(v.string(), v.null()),
declaracaoAcumulacaoCargo: v.union(v.string(), v.null()),
declaracaoDependentesIR: v.union(v.string(), v.null()),
declaracaoIdoneidade: v.union(v.string(), v.null()),
termoNepotismo: v.union(v.string(), v.null()),
termoOpcaoRemuneracao: v.union(v.string(), v.null())
}),
handler: async (ctx, args) => {
const funcionario = await ctx.db.get(args.funcionarioId);
if (!funcionario) {
throw new Error('Funcionário não encontrado');
}
// Tipo exato do retorno para alinhar com o validator
type DocumentUrls = {
certidaoAntecedentesPF: string | null;
certidaoAntecedentesJFPE: string | null;
certidaoAntecedentesSDS: string | null;
certidaoAntecedentesTJPE: string | null;
certidaoImprobidade: string | null;
rgFrente: string | null;
rgVerso: string | null;
cpfFrente: string | null;
cpfVerso: string | null;
situacaoCadastralCPF: string | null;
tituloEleitorFrente: string | null;
tituloEleitorVerso: string | null;
comprovanteVotacao: string | null;
carteiraProfissionalFrente: string | null;
carteiraProfissionalVerso: string | null;
comprovantePIS: string | null;
certidaoRegistroCivil: string | null;
certidaoNascimentoDependentes: string | null;
cpfDependentes: string | null;
reservistaDoc: string | null;
comprovanteEscolaridade: string | null;
comprovanteResidencia: string | null;
comprovanteContaBradesco: string | null;
declaracaoAcumulacaoCargo: string | null;
declaracaoDependentesIR: string | null;
declaracaoIdoneidade: string | null;
termoNepotismo: string | null;
termoOpcaoRemuneracao: string | null;
};
// Tipo exato do retorno para alinhar com o validator
type DocumentUrls = {
certidaoAntecedentesPF: string | null;
certidaoAntecedentesJFPE: string | null;
certidaoAntecedentesSDS: string | null;
certidaoAntecedentesTJPE: string | null;
certidaoImprobidade: string | null;
rgFrente: string | null;
rgVerso: string | null;
cpfFrente: string | null;
cpfVerso: string | null;
situacaoCadastralCPF: string | null;
tituloEleitorFrente: string | null;
tituloEleitorVerso: string | null;
comprovanteVotacao: string | null;
carteiraProfissionalFrente: string | null;
carteiraProfissionalVerso: string | null;
comprovantePIS: string | null;
certidaoRegistroCivil: string | null;
certidaoNascimentoDependentes: string | null;
cpfDependentes: string | null;
reservistaDoc: string | null;
comprovanteEscolaridade: string | null;
comprovanteResidencia: string | null;
comprovanteContaBradesco: string | null;
declaracaoAcumulacaoCargo: string | null;
declaracaoDependentesIR: string | null;
declaracaoIdoneidade: string | null;
termoNepotismo: string | null;
termoOpcaoRemuneracao: string | null;
};
const urls: DocumentUrls = {
certidaoAntecedentesPF: null,
certidaoAntecedentesJFPE: null,
certidaoAntecedentesSDS: null,
certidaoAntecedentesTJPE: null,
certidaoImprobidade: null,
rgFrente: null,
rgVerso: null,
cpfFrente: null,
cpfVerso: null,
situacaoCadastralCPF: null,
tituloEleitorFrente: null,
tituloEleitorVerso: null,
comprovanteVotacao: null,
carteiraProfissionalFrente: null,
carteiraProfissionalVerso: null,
comprovantePIS: null,
certidaoRegistroCivil: null,
certidaoNascimentoDependentes: null,
cpfDependentes: null,
reservistaDoc: null,
comprovanteEscolaridade: null,
comprovanteResidencia: null,
comprovanteContaBradesco: null,
declaracaoAcumulacaoCargo: null,
declaracaoDependentesIR: null,
declaracaoIdoneidade: null,
termoNepotismo: null,
termoOpcaoRemuneracao: null,
};
const urls: DocumentUrls = {
certidaoAntecedentesPF: null,
certidaoAntecedentesJFPE: null,
certidaoAntecedentesSDS: null,
certidaoAntecedentesTJPE: null,
certidaoImprobidade: null,
rgFrente: null,
rgVerso: null,
cpfFrente: null,
cpfVerso: null,
situacaoCadastralCPF: null,
tituloEleitorFrente: null,
tituloEleitorVerso: null,
comprovanteVotacao: null,
carteiraProfissionalFrente: null,
carteiraProfissionalVerso: null,
comprovantePIS: null,
certidaoRegistroCivil: null,
certidaoNascimentoDependentes: null,
cpfDependentes: null,
reservistaDoc: null,
comprovanteEscolaridade: null,
comprovanteResidencia: null,
comprovanteContaBradesco: null,
declaracaoAcumulacaoCargo: null,
declaracaoDependentesIR: null,
declaracaoIdoneidade: null,
termoNepotismo: null,
termoOpcaoRemuneracao: null
};
const campos: Array<keyof DocumentUrls> = [
"certidaoAntecedentesPF",
"certidaoAntecedentesJFPE",
"certidaoAntecedentesSDS",
"certidaoAntecedentesTJPE",
"certidaoImprobidade",
"rgFrente",
"rgVerso",
"cpfFrente",
"cpfVerso",
"situacaoCadastralCPF",
"tituloEleitorFrente",
"tituloEleitorVerso",
"comprovanteVotacao",
"carteiraProfissionalFrente",
"carteiraProfissionalVerso",
"comprovantePIS",
"certidaoRegistroCivil",
"certidaoNascimentoDependentes",
"cpfDependentes",
"reservistaDoc",
"comprovanteEscolaridade",
"comprovanteResidencia",
"comprovanteContaBradesco",
"declaracaoAcumulacaoCargo",
"declaracaoDependentesIR",
"declaracaoIdoneidade",
"termoNepotismo",
"termoOpcaoRemuneracao",
];
const campos: Array<keyof DocumentUrls> = [
'certidaoAntecedentesPF',
'certidaoAntecedentesJFPE',
'certidaoAntecedentesSDS',
'certidaoAntecedentesTJPE',
'certidaoImprobidade',
'rgFrente',
'rgVerso',
'cpfFrente',
'cpfVerso',
'situacaoCadastralCPF',
'tituloEleitorFrente',
'tituloEleitorVerso',
'comprovanteVotacao',
'carteiraProfissionalFrente',
'carteiraProfissionalVerso',
'comprovantePIS',
'certidaoRegistroCivil',
'certidaoNascimentoDependentes',
'cpfDependentes',
'reservistaDoc',
'comprovanteEscolaridade',
'comprovanteResidencia',
'comprovanteContaBradesco',
'declaracaoAcumulacaoCargo',
'declaracaoDependentesIR',
'declaracaoIdoneidade',
'termoNepotismo',
'termoOpcaoRemuneracao'
];
for (const campo of campos) {
const storageId = (funcionario as Record<string, unknown>)[campo as string] as
| Id<"_storage">
| undefined;
if (storageId) {
urls[campo] = await ctx.storage.getUrl(storageId);
} else {
urls[campo] = null;
}
}
for (const campo of campos) {
const storageId = (funcionario as Record<string, unknown>)[campo as string] as
| Id<'_storage'>
| undefined;
if (storageId) {
urls[campo] = await ctx.storage.getUrl(storageId);
} else {
urls[campo] = null;
}
}
return urls;
},
return urls;
}
});
// Query para obter metadados de um documento
export const getDocumentoMetadata = query({
args: { storageId: v.id("_storage") },
returns: v.union(
v.object({
_id: v.id("_storage"),
_creationTime: v.number(),
contentType: v.optional(v.string()),
sha256: v.string(),
size: v.number(),
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db.system.get(args.storageId);
},
args: { storageId: v.id('_storage') },
returns: v.union(
v.object({
_id: v.id('_storage'),
_creationTime: v.number(),
contentType: v.optional(v.string()),
sha256: v.string(),
size: v.number()
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db.system.get(args.storageId);
}
});

View File

@@ -1,11 +1,8 @@
import { v } from "convex/values";
import { mutation, query, internalMutation, internalQuery, action } from "./_generated/server";
import { internal, api } from "./_generated/api";
import {
renderizarTemplateEmailFromDoc,
type VariaveisTemplate,
} from "./templatesMensagens";
import type { Doc, Id } from "./_generated/dataModel";
import { v } from 'convex/values';
import { mutation, query, internalMutation, internalQuery, action } from './_generated/server';
import { internal, api } from './_generated/api';
import { renderizarTemplateEmailFromDoc, type VariaveisTemplate } from './templatesMensagens';
import type { Doc, Id } from './_generated/dataModel';
// ========== INTERNAL QUERIES ==========
@@ -13,45 +10,45 @@ import type { Doc, Id } from "./_generated/dataModel";
* Obter email por ID (internal query)
*/
export const getEmailById = internalQuery({
args: {
emailId: v.id("notificacoesEmail"),
},
handler: async (ctx, args) => {
return await ctx.db.get(args.emailId);
},
args: {
emailId: v.id('notificacoesEmail')
},
handler: async (ctx, args) => {
return await ctx.db.get(args.emailId);
}
});
/**
* Obter configuração SMTP ativa (internal query)
*/
export const getActiveEmailConfig = internalQuery({
args: {},
handler: async (ctx) => {
const config = await ctx.db
.query("configuracaoEmail")
.withIndex("by_ativo", (q) => q.eq("ativo", true))
.first();
args: {},
handler: async (ctx) => {
const config = await ctx.db
.query('configuracaoEmail')
.withIndex('by_ativo', (q) => q.eq('ativo', true))
.first();
return config;
},
return config;
}
});
/**
* Listar emails pendentes (internal query)
*/
export const listarEmailsPendentes = internalQuery({
args: {
limite: v.optional(v.number()),
},
handler: async (ctx, args) => {
const emails = await ctx.db
.query("notificacoesEmail")
.withIndex("by_status", (q) => q.eq("status", "pendente"))
.order("asc") // Mais antigos primeiro
.take(args.limite || 10);
args: {
limite: v.optional(v.number())
},
handler: async (ctx, args) => {
const emails = await ctx.db
.query('notificacoesEmail')
.withIndex('by_status', (q) => q.eq('status', 'pendente'))
.order('asc') // Mais antigos primeiro
.take(args.limite || 10);
return emails;
},
return emails;
}
});
// ========== INTERNAL MUTATIONS ==========
@@ -60,51 +57,51 @@ export const listarEmailsPendentes = internalQuery({
* Marcar email como enviando (internal mutation)
*/
export const markEmailEnviando = internalMutation({
args: {
emailId: v.id("notificacoesEmail"),
},
handler: async (ctx, args) => {
const email = await ctx.db.get(args.emailId);
if (!email) return;
args: {
emailId: v.id('notificacoesEmail')
},
handler: async (ctx, args) => {
const email = await ctx.db.get(args.emailId);
if (!email) return;
await ctx.db.patch(args.emailId, {
status: "enviando",
ultimaTentativa: Date.now(),
tentativas: email.tentativas + 1,
});
},
await ctx.db.patch(args.emailId, {
status: 'enviando',
ultimaTentativa: Date.now(),
tentativas: email.tentativas + 1
});
}
});
/**
* Marcar email como enviado (internal mutation)
*/
export const markEmailEnviado = internalMutation({
args: {
emailId: v.id("notificacoesEmail"),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.emailId, {
status: "enviado",
enviadoEm: Date.now(),
});
},
args: {
emailId: v.id('notificacoesEmail')
},
handler: async (ctx, args) => {
await ctx.db.patch(args.emailId, {
status: 'enviado',
enviadoEm: Date.now()
});
}
});
/**
* Marcar email como falha (internal mutation)
*/
export const markEmailFalha = internalMutation({
args: {
emailId: v.id("notificacoesEmail"),
erro: v.string(),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.emailId, {
status: "falha",
erroDetalhes: args.erro,
ultimaTentativa: Date.now(),
});
},
args: {
emailId: v.id('notificacoesEmail'),
erro: v.string()
},
handler: async (ctx, args) => {
await ctx.db.patch(args.emailId, {
status: 'falha',
erroDetalhes: args.erro,
ultimaTentativa: Date.now()
});
}
});
// ========== PUBLIC MUTATIONS ==========
@@ -113,136 +110,134 @@ export const markEmailFalha = internalMutation({
* Enfileirar email para envio assíncrono
*/
export const enfileirarEmail = mutation({
args: {
destinatario: v.string(),
destinatarioId: v.optional(v.id("usuarios")),
assunto: v.string(),
corpo: v.string(),
templateId: v.optional(v.id("templatesMensagens")),
enviadoPor: v.id("usuarios"), // Obrigatório conforme schema
agendadaPara: v.optional(v.number()), // timestamp opcional para agendamento
},
handler: async (ctx, args) => {
// Validar agendamento se fornecido
if (args.agendadaPara !== undefined && args.agendadaPara <= Date.now()) {
throw new Error("Data de agendamento deve ser futura");
}
args: {
destinatario: v.string(),
destinatarioId: v.optional(v.id('usuarios')),
assunto: v.string(),
corpo: v.string(),
templateId: v.optional(v.id('templatesMensagens')),
enviadoPor: v.id('usuarios'), // Obrigatório conforme schema
agendadaPara: v.optional(v.number()) // timestamp opcional para agendamento
},
handler: async (ctx, args) => {
// Validar agendamento se fornecido
if (args.agendadaPara !== undefined && args.agendadaPara <= Date.now()) {
throw new Error('Data de agendamento deve ser futura');
}
const emailId = await ctx.db.insert("notificacoesEmail", {
destinatario: args.destinatario,
destinatarioId: args.destinatarioId,
assunto: args.assunto,
corpo: args.corpo,
templateId: args.templateId,
status: "pendente",
tentativas: 0,
criadoEm: Date.now(),
enviadoPor: args.enviadoPor,
agendadaPara: args.agendadaPara,
});
const emailId = await ctx.db.insert('notificacoesEmail', {
destinatario: args.destinatario,
destinatarioId: args.destinatarioId,
assunto: args.assunto,
corpo: args.corpo,
templateId: args.templateId,
status: 'pendente',
tentativas: 0,
criadoEm: Date.now(),
enviadoPor: args.enviadoPor,
agendadaPara: args.agendadaPara
});
// Processar imediatamente se não houver agendamento ou se o agendamento já passou
const agora = Date.now();
const deveProcessarAgora =
args.agendadaPara === undefined ||
args.agendadaPara <= agora;
// Processar imediatamente se não houver agendamento ou se o agendamento já passou
const agora = Date.now();
const deveProcessarAgora = args.agendadaPara === undefined || args.agendadaPara <= agora;
if (deveProcessarAgora) {
// Agendar envio imediato via action (não bloqueia a mutation)
ctx.scheduler
.runAfter(0, api.actions.email.enviar, {
emailId: emailId,
})
.catch((error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Erro ao agendar envio imediato de email ${emailId}:`, errorMessage);
// Não falha a mutation se houver erro ao agendar - o cron pode processar depois
});
}
// Emails agendados para o futuro serão processados pelo cron quando a hora chegar
if (deveProcessarAgora) {
// Agendar envio imediato via action (não bloqueia a mutation)
ctx.scheduler
.runAfter(0, api.actions.email.enviar, {
emailId: emailId
})
.catch((error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Erro ao agendar envio imediato de email ${emailId}:`, errorMessage);
// Não falha a mutation se houver erro ao agendar - o cron pode processar depois
});
}
// Emails agendados para o futuro serão processados pelo cron quando a hora chegar
return emailId;
},
return emailId;
}
});
/**
* Cancelar agendamento de email
*/
export const cancelarAgendamentoEmail = mutation({
args: {
emailId: v.id("notificacoesEmail"),
},
handler: async (ctx, args) => {
const email = await ctx.db.get(args.emailId);
if (!email) {
return { sucesso: false, erro: "Email não encontrado" };
}
args: {
emailId: v.id('notificacoesEmail')
},
handler: async (ctx, args) => {
const email = await ctx.db.get(args.emailId);
if (!email) {
return { sucesso: false, erro: 'Email não encontrado' };
}
if (email.status !== "pendente") {
return { sucesso: false, erro: "Apenas emails pendentes podem ser cancelados" };
}
if (email.status !== 'pendente') {
return { sucesso: false, erro: 'Apenas emails pendentes podem ser cancelados' };
}
// Remove o email da fila
await ctx.db.delete(args.emailId);
return { sucesso: true };
},
// Remove o email da fila
await ctx.db.delete(args.emailId);
return { sucesso: true };
}
});
/**
* Enviar email usando template
*/
export const enviarEmailComTemplate = action({
args: {
destinatario: v.string(),
destinatarioId: v.optional(v.id("usuarios")),
templateCodigo: v.string(),
variaveis: v.optional(v.record(v.string(), v.string())),
enviadoPor: v.id("usuarios"), // Obrigatório conforme schema
agendadaPara: v.optional(v.number()), // timestamp opcional para agendamento
},
handler: async (ctx, args): Promise<Id<"notificacoesEmail">> => {
// Buscar template
const template: Doc<"templatesMensagens"> | null = await ctx.runQuery(
api.templatesMensagens.obterTemplatePorCodigo,
{
codigo: args.templateCodigo,
}
);
args: {
destinatario: v.string(),
destinatarioId: v.optional(v.id('usuarios')),
templateCodigo: v.string(),
variaveis: v.optional(v.record(v.string(), v.string())),
enviadoPor: v.id('usuarios'), // Obrigatório conforme schema
agendadaPara: v.optional(v.number()) // timestamp opcional para agendamento
},
handler: async (ctx, args): Promise<Id<'notificacoesEmail'>> => {
// Buscar template
const template: Doc<'templatesMensagens'> | null = await ctx.runQuery(
api.templatesMensagens.obterTemplatePorCodigo,
{
codigo: args.templateCodigo
}
);
if (!template) {
throw new Error(`Template não encontrado: ${args.templateCodigo}`);
}
if (!template) {
throw new Error(`Template não encontrado: ${args.templateCodigo}`);
}
// Renderizar template com variáveis
const variaveisTemplate: VariaveisTemplate = args.variaveis ?? {};
// Renderizar template com variáveis
const variaveisTemplate: VariaveisTemplate = args.variaveis ?? {};
// Garantir que urlSistema sempre tenha protocolo se presente
if (
typeof variaveisTemplate.urlSistema === "string" &&
!variaveisTemplate.urlSistema.match(/^https?:\/\//i)
) {
variaveisTemplate.urlSistema = `http://${variaveisTemplate.urlSistema}`;
}
// Garantir que urlSistema sempre tenha protocolo se presente
if (
typeof variaveisTemplate.urlSistema === 'string' &&
!variaveisTemplate.urlSistema.match(/^https?:\/\//i)
) {
variaveisTemplate.urlSistema = `http://${variaveisTemplate.urlSistema}`;
}
const emailRenderizado = renderizarTemplateEmailFromDoc(template, variaveisTemplate);
const emailRenderizado = renderizarTemplateEmailFromDoc(template, variaveisTemplate);
// Enfileirar email via mutation
const emailId: Id<"notificacoesEmail"> = await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: args.destinatario,
destinatarioId: args.destinatarioId,
assunto: emailRenderizado.titulo,
corpo: emailRenderizado.html, // HTML completo com wrapper
templateId: template._id, // template._id sempre existe se template não é null
enviadoPor: args.enviadoPor,
agendadaPara: args.agendadaPara,
});
// Enfileirar email via mutation
const emailId: Id<'notificacoesEmail'> = await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: args.destinatario,
destinatarioId: args.destinatarioId,
assunto: emailRenderizado.titulo,
corpo: emailRenderizado.html, // HTML completo com wrapper
templateId: template._id, // template._id sempre existe se template não é null
enviadoPor: args.enviadoPor,
agendadaPara: args.agendadaPara
});
if (!emailId) {
throw new Error("Erro ao enfileirar email: ID não retornado");
}
if (!emailId) {
throw new Error('Erro ao enfileirar email: ID não retornado');
}
return emailId;
},
return emailId;
}
});
// ========== INTERNAL MUTATION (CRON) ==========
@@ -251,47 +246,46 @@ export const enviarEmailComTemplate = action({
* Processar fila de emails pendentes (chamado pelo cron)
*/
export const processarFilaEmails = internalMutation({
args: {},
handler: async (ctx) => {
const agora = Date.now();
// Buscar emails pendentes que devem ser processados agora
// (sem agendamento OU com agendamento que já passou)
const emailsParaProcessar = await ctx.db
.query("notificacoesEmail")
.filter((q) => {
const statusPendente = q.eq(q.field("status"), "pendente");
const semAgendamento = q.eq(q.field("agendadaPara"), undefined);
const agendamentoJaPassou = q.and(
q.neq(q.field("agendadaPara"), undefined),
q.lte(q.field("agendadaPara"), agora)
);
return q.and(
statusPendente,
q.or(semAgendamento, agendamentoJaPassou)
);
})
.order("asc") // Mais antigos primeiro
.take(10);
args: {},
handler: async (ctx) => {
const agora = Date.now();
if (emailsParaProcessar.length === 0) {
return { processados: 0 };
}
// Buscar emails pendentes que devem ser processados agora
// (sem agendamento OU com agendamento que já passou)
const emailsParaProcessar = await ctx.db
.query('notificacoesEmail')
.filter((q) => {
const statusPendente = q.eq(q.field('status'), 'pendente');
const semAgendamento = q.eq(q.field('agendadaPara'), undefined);
const agendamentoJaPassou = q.and(
q.neq(q.field('agendadaPara'), undefined),
q.lte(q.field('agendadaPara'), agora)
);
// Agendar envio de cada email via action
for (const email of emailsParaProcessar) {
// Agendar envio assíncrono (não bloqueia o cron)
ctx.scheduler.runAfter(0, api.actions.email.enviar, {
emailId: email._id,
}).catch((error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Erro ao agendar envio de email ${email._id}:`, errorMessage);
});
}
return q.and(statusPendente, q.or(semAgendamento, agendamentoJaPassou));
})
.order('asc') // Mais antigos primeiro
.take(10);
return { processados: emailsParaProcessar.length };
}
if (emailsParaProcessar.length === 0) {
return { processados: 0 };
}
// Agendar envio de cada email via action
for (const email of emailsParaProcessar) {
// Agendar envio assíncrono (não bloqueia o cron)
ctx.scheduler
.runAfter(0, api.actions.email.enviar, {
emailId: email._id
})
.catch((error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Erro ao agendar envio de email ${email._id}:`, errorMessage);
});
}
return { processados: emailsParaProcessar.length };
}
});
// ========== QUERIES ==========
@@ -300,138 +294,138 @@ export const processarFilaEmails = internalMutation({
* Listar emails da fila (para monitoramento)
*/
export const listarFilaEmails = query({
args: {
limite: v.optional(v.number()),
status: v.optional(v.union(
v.literal("pendente"),
v.literal("enviando"),
v.literal("enviado"),
v.literal("falha")
)),
_refresh: v.optional(v.number()), // Parâmetro ignorado, usado apenas para forçar refresh no frontend
},
handler: async (ctx, args) => {
let emails;
args: {
limite: v.optional(v.number()),
status: v.optional(
v.union(
v.literal('pendente'),
v.literal('enviando'),
v.literal('enviado'),
v.literal('falha')
)
),
_refresh: v.optional(v.number()) // Parâmetro ignorado, usado apenas para forçar refresh no frontend
},
handler: async (ctx, args) => {
let emails;
// Filtrar por status se fornecido
if (args.status) {
emails = await ctx.db
.query("notificacoesEmail")
.withIndex("by_status", (q) => q.eq("status", args.status!))
.order("desc")
.take(args.limite || 50);
} else {
// Sem filtro, buscar todos e ordenar por data de criação
const todosEmails = await ctx.db.query("notificacoesEmail").collect();
todosEmails.sort((a, b) => b.criadoEm - a.criadoEm);
emails = todosEmails.slice(0, args.limite || 50);
}
// Filtrar por status se fornecido
if (args.status) {
emails = await ctx.db
.query('notificacoesEmail')
.withIndex('by_status', (q) => q.eq('status', args.status!))
.order('desc')
.take(args.limite || 50);
} else {
// Sem filtro, buscar todos e ordenar por data de criação
const todosEmails = await ctx.db.query('notificacoesEmail').collect();
todosEmails.sort((a, b) => b.criadoEm - a.criadoEm);
emails = todosEmails.slice(0, args.limite || 50);
}
return emails;
},
return emails;
}
});
/**
* Obter estatísticas da fila de emails (para debug e monitoramento)
*/
export const obterEstatisticasFilaEmails = query({
args: {
_refresh: v.optional(v.number()), // Parâmetro ignorado, usado apenas para forçar refresh no frontend
},
returns: v.object({
pendentes: v.number(),
enviando: v.number(),
enviados: v.number(),
falhas: v.number(),
total: v.number(),
}),
handler: async (ctx) => {
const todosEmails = await ctx.db.query("notificacoesEmail").collect();
const estatisticas = {
pendentes: 0,
enviando: 0,
enviados: 0,
falhas: 0,
total: todosEmails.length,
};
args: {
_refresh: v.optional(v.number()) // Parâmetro ignorado, usado apenas para forçar refresh no frontend
},
returns: v.object({
pendentes: v.number(),
enviando: v.number(),
enviados: v.number(),
falhas: v.number(),
total: v.number()
}),
handler: async (ctx) => {
const todosEmails = await ctx.db.query('notificacoesEmail').collect();
for (const email of todosEmails) {
switch (email.status) {
case "pendente":
estatisticas.pendentes++;
break;
case "enviando":
estatisticas.enviando++;
break;
case "enviado":
estatisticas.enviados++;
break;
case "falha":
estatisticas.falhas++;
break;
}
}
const estatisticas = {
pendentes: 0,
enviando: 0,
enviados: 0,
falhas: 0,
total: todosEmails.length
};
return estatisticas;
},
for (const email of todosEmails) {
switch (email.status) {
case 'pendente':
estatisticas.pendentes++;
break;
case 'enviando':
estatisticas.enviando++;
break;
case 'enviado':
estatisticas.enviados++;
break;
case 'falha':
estatisticas.falhas++;
break;
}
}
return estatisticas;
}
});
/**
* Buscar emails por IDs (para monitoramento de status)
*/
export const buscarEmailsPorIds = query({
args: {
emailIds: v.array(v.id("notificacoesEmail")),
},
handler: async (ctx, args) => {
const emails = [];
for (const emailId of args.emailIds) {
const email = await ctx.db.get(emailId);
if (email) {
emails.push(email);
}
}
return emails;
},
args: {
emailIds: v.array(v.id('notificacoesEmail'))
},
handler: async (ctx, args) => {
const emails = [];
for (const emailId of args.emailIds) {
const email = await ctx.db.get(emailId);
if (email) {
emails.push(email);
}
}
return emails;
}
});
/**
* Listar agendamentos de email (emails com agendadaPara definido)
*/
export const listarAgendamentosEmail = query({
args: {},
handler: async (ctx) => {
// Buscar todos os emails agendados (pendentes, enviando ou já enviados que tinham agendamento)
const emailsAgendados = await ctx.db
.query("notificacoesEmail")
.filter((q) => {
// Apenas emails que têm agendadaPara definido
return q.neq(q.field("agendadaPara"), undefined);
})
.collect();
args: {},
handler: async (ctx) => {
// Buscar todos os emails agendados (pendentes, enviando ou já enviados que tinham agendamento)
const emailsAgendados = await ctx.db
.query('notificacoesEmail')
.filter((q) => {
// Apenas emails que têm agendadaPara definido
return q.neq(q.field('agendadaPara'), undefined);
})
.collect();
// Enriquecer com informações de destinatário e template
const emailsEnriquecidos = await Promise.all(
emailsAgendados.map(async (email) => {
const destinatarioInfo = email.destinatarioId
? await ctx.db.get(email.destinatarioId)
: null;
const templateInfo = email.templateId
? await ctx.db.get(email.templateId)
: null;
// Enriquecer com informações de destinatário e template
const emailsEnriquecidos = await Promise.all(
emailsAgendados.map(async (email) => {
const destinatarioInfo = email.destinatarioId
? await ctx.db.get(email.destinatarioId)
: null;
return {
...email,
destinatarioInfo,
templateInfo,
};
})
);
const templateInfo = email.templateId ? await ctx.db.get(email.templateId) : null;
return emailsEnriquecidos;
},
return {
...email,
destinatarioInfo,
templateInfo
};
})
);
return emailsEnriquecidos;
}
});
// ========== PUBLIC MUTATIONS (MANUAL) ==========
@@ -440,53 +434,53 @@ export const listarAgendamentosEmail = query({
* Processar fila de emails manualmente (para uso em interface)
*/
export const processarFilaEmailsManual = action({
args: {
limite: v.optional(v.number()),
},
returns: v.object({
sucesso: v.boolean(),
processados: v.number(),
falhas: v.number(),
erro: v.optional(v.string()),
}),
handler: async (ctx, args) => {
try {
// Buscar emails pendentes
const emailsPendentes = await ctx.runQuery(internal.email.listarEmailsPendentes, {
limite: args.limite || 10,
});
args: {
limite: v.optional(v.number())
},
returns: v.object({
sucesso: v.boolean(),
processados: v.number(),
falhas: v.number(),
erro: v.optional(v.string())
}),
handler: async (ctx, args) => {
try {
// Buscar emails pendentes
const emailsPendentes = await ctx.runQuery(internal.email.listarEmailsPendentes, {
limite: args.limite || 10
});
if (emailsPendentes.length === 0) {
return { sucesso: true, processados: 0, falhas: 0 };
}
if (emailsPendentes.length === 0) {
return { sucesso: true, processados: 0, falhas: 0 };
}
let processados = 0;
let falhas = 0;
let processados = 0;
let falhas = 0;
// Processar cada email
for (const email of emailsPendentes) {
try {
// Agendar envio via action
await ctx.scheduler.runAfter(0, api.actions.email.enviar, {
emailId: email._id,
});
processados++;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Erro ao agendar envio de email ${email._id}:`, errorMessage);
falhas++;
}
}
// Processar cada email
for (const email of emailsPendentes) {
try {
// Agendar envio via action
await ctx.scheduler.runAfter(0, api.actions.email.enviar, {
emailId: email._id
});
processados++;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Erro ao agendar envio de email ${email._id}:`, errorMessage);
falhas++;
}
}
return { sucesso: true, processados, falhas };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
sucesso: false,
processados: 0,
falhas: 0,
erro: errorMessage,
};
}
},
return { sucesso: true, processados, falhas };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
sucesso: false,
processados: 0,
falhas: 0,
erro: errorMessage
};
}
}
});

View File

@@ -1,292 +1,290 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { internal } from "./_generated/api";
import { getCurrentUserFunction } from "./auth";
import type { Id } from "./_generated/dataModel";
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
import { internal } from './_generated/api';
import { getCurrentUserFunction } from './auth';
import type { Id } from './_generated/dataModel';
export const list = query({
args: {},
handler: async (ctx) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "empresas",
acao: "listar",
});
args: {},
handler: async (ctx) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: 'empresas',
acao: 'listar'
});
const empresas = await ctx.db.query("empresas").collect();
return empresas;
},
const empresas = await ctx.db.query('empresas').collect();
return empresas;
}
});
export const getById = query({
args: { id: v.id("empresas") },
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "empresas",
acao: "ver",
});
args: { id: v.id('empresas') },
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: 'empresas',
acao: 'ver'
});
const empresa = await ctx.db.get(args.id);
if (!empresa) {
return null;
}
const empresa = await ctx.db.get(args.id);
if (!empresa) {
return null;
}
const contatos = await ctx.db
.query("contatosEmpresa")
.withIndex("by_empresa", (q) => q.eq("empresaId", args.id))
.collect();
const endereco = empresa.enderecoId
? await ctx.db.get(empresa.enderecoId as Id<"enderecos">)
: null;
const contatos = await ctx.db
.query('contatosEmpresa')
.withIndex('by_empresa', (q) => q.eq('empresaId', args.id))
.collect();
const endereco = empresa.enderecoId
? await ctx.db.get(empresa.enderecoId as Id<'enderecos'>)
: null;
return { ...empresa, endereco, contatos };
},
return { ...empresa, endereco, contatos };
}
});
const contatoInput = v.object({
_id: v.optional(v.id("contatosEmpresa")),
empresaId: v.optional(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()),
_deleted: v.optional(v.boolean()),
_id: v.optional(v.id('contatosEmpresa')),
empresaId: v.optional(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()),
_deleted: v.optional(v.boolean())
});
const enderecoInput = v.object({
cep: v.string(),
logradouro: v.string(),
numero: v.string(),
complemento: v.optional(v.string()),
bairro: v.string(),
cidade: v.string(),
uf: v.string(),
cep: v.string(),
logradouro: v.string(),
numero: v.string(),
complemento: v.optional(v.string()),
bairro: v.string(),
cidade: v.string(),
uf: v.string()
});
export const create = mutation({
args: {
razao_social: v.string(),
nome_fantasia: v.optional(v.string()),
cnpj: v.string(),
telefone: v.string(),
email: v.string(),
descricao: v.optional(v.string()),
endereco: v.optional(enderecoInput),
contatos: v.optional(v.array(contatoInput)),
},
returns: v.id("empresas"),
handler: async (ctx, args) => {
const usuarioAtual = await getCurrentUserFunction(ctx);
args: {
razao_social: v.string(),
nome_fantasia: v.optional(v.string()),
cnpj: v.string(),
telefone: v.string(),
email: v.string(),
descricao: v.optional(v.string()),
endereco: v.optional(enderecoInput),
contatos: v.optional(v.array(contatoInput))
},
returns: v.id('empresas'),
handler: async (ctx, args) => {
const usuarioAtual = await getCurrentUserFunction(ctx);
if (!usuarioAtual) {
throw new Error("Usuário não autenticado.");
}
if (!usuarioAtual) {
throw new Error('Usuário não autenticado.');
}
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "empresas",
acao: "criar",
});
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: 'empresas',
acao: 'criar'
});
const cnpjExistente = await ctx.db
.query("empresas")
.withIndex("by_cnpj", (q) => q.eq("cnpj", args.cnpj))
.unique();
const cnpjExistente = await ctx.db
.query('empresas')
.withIndex('by_cnpj', (q) => q.eq('cnpj', args.cnpj))
.unique();
if (cnpjExistente) {
throw new Error("Já existe uma empresa cadastrada com este CNPJ.");
}
let enderecoId: Id<"enderecos"> | undefined;
if (args.endereco) {
enderecoId = await ctx.db.insert("enderecos", {
cep: args.endereco.cep,
logradouro: args.endereco.logradouro,
numero: args.endereco.numero,
complemento: args.endereco.complemento,
bairro: args.endereco.bairro,
cidade: args.endereco.cidade,
uf: args.endereco.uf,
criadoPor: usuarioAtual._id,
atualizadoPor: usuarioAtual._id,
});
}
if (cnpjExistente) {
throw new Error('Já existe uma empresa cadastrada com este CNPJ.');
}
let enderecoId: Id<'enderecos'> | undefined;
if (args.endereco) {
enderecoId = await ctx.db.insert('enderecos', {
cep: args.endereco.cep,
logradouro: args.endereco.logradouro,
numero: args.endereco.numero,
complemento: args.endereco.complemento,
bairro: args.endereco.bairro,
cidade: args.endereco.cidade,
uf: args.endereco.uf,
criadoPor: usuarioAtual._id,
atualizadoPor: usuarioAtual._id
});
}
const empresaDoc: {
razao_social: string;
nome_fantasia?: string;
cnpj: string;
telefone: string;
email: string;
descricao?: string;
enderecoId?: Id<"enderecos">;
criadoPor: Id<"usuarios">;
} = {
razao_social: args.razao_social,
cnpj: args.cnpj,
telefone: args.telefone,
email: args.email,
criadoPor: usuarioAtual._id,
};
const empresaDoc: {
razao_social: string;
nome_fantasia?: string;
cnpj: string;
telefone: string;
email: string;
descricao?: string;
enderecoId?: Id<'enderecos'>;
criadoPor: Id<'usuarios'>;
} = {
razao_social: args.razao_social,
cnpj: args.cnpj,
telefone: args.telefone,
email: args.email,
criadoPor: usuarioAtual._id
};
if (args.nome_fantasia !== undefined) {
empresaDoc.nome_fantasia = args.nome_fantasia;
}
if (args.descricao !== undefined) {
empresaDoc.descricao = args.descricao;
}
if (enderecoId) {
empresaDoc.enderecoId = enderecoId;
}
if (args.nome_fantasia !== undefined) {
empresaDoc.nome_fantasia = args.nome_fantasia;
}
if (args.descricao !== undefined) {
empresaDoc.descricao = args.descricao;
}
if (enderecoId) {
empresaDoc.enderecoId = enderecoId;
}
const empresaId = await ctx.db.insert("empresas", empresaDoc);
const empresaId = await ctx.db.insert('empresas', empresaDoc);
if (args.contatos && args.contatos.length > 0) {
for (const contato of args.contatos) {
await ctx.db.insert("contatosEmpresa", {
empresaId,
nome: contato.nome,
funcao: contato.funcao,
email: contato.email,
telefone: contato.telefone,
adicionadoPor: usuarioAtual._id,
descricao: contato.descricao,
});
}
}
if (args.contatos && args.contatos.length > 0) {
for (const contato of args.contatos) {
await ctx.db.insert('contatosEmpresa', {
empresaId,
nome: contato.nome,
funcao: contato.funcao,
email: contato.email,
telefone: contato.telefone,
adicionadoPor: usuarioAtual._id,
descricao: contato.descricao
});
}
}
return empresaId;
},
return empresaId;
}
});
export const update = mutation({
args: {
id: v.id("empresas"),
razao_social: v.string(),
nome_fantasia: v.optional(v.string()),
cnpj: v.string(),
telefone: v.string(),
email: v.string(),
descricao: v.optional(v.string()),
endereco: v.optional(enderecoInput),
contatos: v.optional(v.array(contatoInput)),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "empresas",
acao: "editar",
});
args: {
id: v.id('empresas'),
razao_social: v.string(),
nome_fantasia: v.optional(v.string()),
cnpj: v.string(),
telefone: v.string(),
email: v.string(),
descricao: v.optional(v.string()),
endereco: v.optional(enderecoInput),
contatos: v.optional(v.array(contatoInput))
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: 'empresas',
acao: 'editar'
});
const cnpjExistente = await ctx.db
.query("empresas")
.withIndex("by_cnpj", (q) => q.eq("cnpj", args.cnpj))
.unique();
const cnpjExistente = await ctx.db
.query('empresas')
.withIndex('by_cnpj', (q) => q.eq('cnpj', args.cnpj))
.unique();
if (cnpjExistente && cnpjExistente._id !== args.id) {
throw new Error("Já existe uma empresa cadastrada com este CNPJ.");
}
const empresa = await ctx.db.get(args.id);
if (!empresa) {
throw new Error("Empresa não encontrada.");
}
if (cnpjExistente && cnpjExistente._id !== args.id) {
throw new Error('Já existe uma empresa cadastrada com este CNPJ.');
}
const empresa = await ctx.db.get(args.id);
if (!empresa) {
throw new Error('Empresa não encontrada.');
}
if (args.endereco) {
if (empresa.enderecoId) {
const usuarioAtual = await getCurrentUserFunction(ctx);
await ctx.db.patch(empresa.enderecoId as Id<"enderecos">, {
cep: args.endereco.cep,
logradouro: args.endereco.logradouro,
numero: args.endereco.numero,
complemento: args.endereco.complemento,
bairro: args.endereco.bairro,
cidade: args.endereco.cidade,
uf: args.endereco.uf,
atualizadoPor: usuarioAtual?._id,
});
} else {
const usuarioAtual = await getCurrentUserFunction(ctx);
if (args.endereco) {
if (empresa.enderecoId) {
const usuarioAtual = await getCurrentUserFunction(ctx);
await ctx.db.patch(empresa.enderecoId as Id<'enderecos'>, {
cep: args.endereco.cep,
logradouro: args.endereco.logradouro,
numero: args.endereco.numero,
complemento: args.endereco.complemento,
bairro: args.endereco.bairro,
cidade: args.endereco.cidade,
uf: args.endereco.uf,
atualizadoPor: usuarioAtual?._id
});
} else {
const usuarioAtual = await getCurrentUserFunction(ctx);
if (!usuarioAtual) {
throw new Error("Usuário não autenticado.");
}
if (!usuarioAtual) {
throw new Error('Usuário não autenticado.');
}
const novoEnderecoId: Id<"enderecos"> = await ctx.db.insert("enderecos", {
cep: args.endereco.cep,
logradouro: args.endereco.logradouro,
numero: args.endereco.numero,
complemento: args.endereco.complemento,
bairro: args.endereco.bairro,
cidade: args.endereco.cidade,
uf: args.endereco.uf,
criadoPor: usuarioAtual._id,
atualizadoPor: usuarioAtual._id,
});
const novoEnderecoId: Id<'enderecos'> = await ctx.db.insert('enderecos', {
cep: args.endereco.cep,
logradouro: args.endereco.logradouro,
numero: args.endereco.numero,
complemento: args.endereco.complemento,
bairro: args.endereco.bairro,
cidade: args.endereco.cidade,
uf: args.endereco.uf,
criadoPor: usuarioAtual._id,
atualizadoPor: usuarioAtual._id
});
await ctx.db.patch(args.id, {
enderecoId: novoEnderecoId,
});
}
}
await ctx.db.patch(args.id, {
enderecoId: novoEnderecoId
});
}
}
const patchDoc: {
razao_social: string;
nome_fantasia?: string;
cnpj: string;
telefone: string;
email: string;
descricao?: string;
} = {
razao_social: args.razao_social,
cnpj: args.cnpj,
telefone: args.telefone,
email: args.email,
};
const patchDoc: {
razao_social: string;
nome_fantasia?: string;
cnpj: string;
telefone: string;
email: string;
descricao?: string;
} = {
razao_social: args.razao_social,
cnpj: args.cnpj,
telefone: args.telefone,
email: args.email
};
if (args.nome_fantasia !== undefined) {
patchDoc.nome_fantasia = args.nome_fantasia;
}
if (args.descricao !== undefined) {
patchDoc.descricao = args.descricao;
}
if (args.nome_fantasia !== undefined) {
patchDoc.nome_fantasia = args.nome_fantasia;
}
if (args.descricao !== undefined) {
patchDoc.descricao = args.descricao;
}
await ctx.db.patch(args.id, patchDoc);
await ctx.db.patch(args.id, patchDoc);
if (!args.contatos) {
return null;
}
if (!args.contatos) {
return null;
}
for (const contato of args.contatos) {
if (contato._id && contato._deleted) {
await ctx.db.delete(contato._id);
} else if (contato._id) {
await ctx.db.patch(contato._id, {
nome: contato.nome,
funcao: contato.funcao,
email: contato.email,
telefone: contato.telefone,
descricao: contato.descricao,
});
} else if (!contato._deleted) {
const usuarioAtual = await getCurrentUserFunction(ctx);
for (const contato of args.contatos) {
if (contato._id && contato._deleted) {
await ctx.db.delete(contato._id);
} else if (contato._id) {
await ctx.db.patch(contato._id, {
nome: contato.nome,
funcao: contato.funcao,
email: contato.email,
telefone: contato.telefone,
descricao: contato.descricao
});
} else if (!contato._deleted) {
const usuarioAtual = await getCurrentUserFunction(ctx);
if (!usuarioAtual) {
throw new Error("Usuário não autenticado.");
}
if (!usuarioAtual) {
throw new Error('Usuário não autenticado.');
}
await ctx.db.insert("contatosEmpresa", {
empresaId: args.id,
nome: contato.nome,
funcao: contato.funcao,
email: contato.email,
telefone: contato.telefone,
adicionadoPor: usuarioAtual._id,
descricao: contato.descricao,
});
}
}
await ctx.db.insert('contatosEmpresa', {
empresaId: args.id,
nome: contato.nome,
funcao: contato.funcao,
email: contato.email,
telefone: contato.telefone,
adicionadoPor: usuarioAtual._id,
descricao: contato.descricao
});
}
}
return null;
},
return null;
}
});

View File

@@ -8,12 +8,7 @@ import type { MutationCtx } from './_generated/server';
* Calcula distância entre duas coordenadas (fórmula de Haversine)
* Retorna distância em metros
*/
function calcularDistancia(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number {
function calcularDistancia(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000; // Raio da Terra em metros
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
@@ -32,7 +27,7 @@ function calcularDistancia(
*/
export const listarEnderecos = query({
args: {
incluirInativos: v.optional(v.boolean()),
incluirInativos: v.optional(v.boolean())
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -52,7 +47,7 @@ export const listarEnderecos = query({
// Ordenar por nome
return enderecos.sort((a, b) => a.nome.localeCompare(b.nome));
},
}
});
/**
@@ -60,7 +55,7 @@ export const listarEnderecos = query({
*/
export const obterEndereco = query({
args: {
enderecoId: v.id('enderecosMarcacao'),
enderecoId: v.id('enderecosMarcacao')
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -74,7 +69,7 @@ export const obterEndereco = query({
}
return endereco;
},
}
});
/**
@@ -98,7 +93,7 @@ export const criarEndereco = mutation({
v.literal('home_office'),
v.literal('deslocamento'),
v.literal('cliente')
),
)
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -155,11 +150,11 @@ export const criarEndereco = mutation({
tipo: args.tipo,
ativo: true,
criadoPor: usuario._id as Id<'usuarios'>,
criadoEm: Date.now(),
criadoEm: Date.now()
});
return { enderecoId };
},
}
});
/**
@@ -187,7 +182,7 @@ export const atualizarEndereco = mutation({
v.literal('deslocamento'),
v.literal('cliente')
)
),
)
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -235,14 +230,7 @@ export const atualizarEndereco = mutation({
const lat = args.latitude ?? endereco.latitude;
const lon = args.longitude ?? endereco.longitude;
if (
isNaN(lat) ||
lat < -90 ||
lat > 90 ||
isNaN(lon) ||
lon < -180 ||
lon > 180
) {
if (isNaN(lat) || lat < -90 || lat > 90 || isNaN(lon) || lon < -180 || lon > 180) {
throw new Error('Coordenadas inválidas');
}
@@ -304,7 +292,7 @@ export const atualizarEndereco = mutation({
await ctx.db.patch(args.enderecoId, atualizacoes);
return { sucesso: true };
},
}
});
/**
@@ -312,7 +300,7 @@ export const atualizarEndereco = mutation({
*/
export const desativarEndereco = mutation({
args: {
enderecoId: v.id('enderecosMarcacao'),
enderecoId: v.id('enderecosMarcacao')
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -330,11 +318,11 @@ export const desativarEndereco = mutation({
await ctx.db.patch(args.enderecoId, {
ativo: false,
atualizadoPor: usuario._id as Id<'usuarios'>,
atualizadoEm: Date.now(),
atualizadoEm: Date.now()
});
return { sucesso: true };
},
}
});
/**
@@ -344,7 +332,7 @@ export const desativarEndereco = mutation({
export const obterEnderecosFuncionario = query({
args: {
funcionarioId: v.id('funcionarios'),
dataAtual: v.optional(v.string()), // YYYY-MM-DD para validar períodos
dataAtual: v.optional(v.string()) // YYYY-MM-DD para validar períodos
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -387,13 +375,12 @@ export const obterEnderecosFuncionario = query({
if (!endereco || !endereco.ativo) continue;
// Usar raio personalizado se disponível, senão usar o raio do endereço
const raioMetros =
associacao.raioMetrosPersonalizado ?? endereco.raioMetros;
const raioMetros = associacao.raioMetrosPersonalizado ?? endereco.raioMetros;
enderecosPermitidos.push({
enderecoId: endereco._id,
raioMetros,
periodoValido: true,
periodoValido: true
});
}
@@ -409,7 +396,7 @@ export const obterEnderecosFuncionario = query({
enderecosPermitidos.push({
enderecoId: endereco._id,
raioMetros: endereco.raioMetros,
periodoValido: true,
periodoValido: true
});
}
}
@@ -423,7 +410,7 @@ export const obterEnderecosFuncionario = query({
return {
...endereco,
raioMetros: item.raioMetros,
periodoValido: item.periodoValido,
periodoValido: item.periodoValido
};
})
);
@@ -431,7 +418,7 @@ export const obterEnderecosFuncionario = query({
return enderecosCompletos.filter(
(endereco): endereco is NonNullable<typeof endereco> => endereco !== null
);
},
}
});
/**
@@ -465,7 +452,7 @@ async function validarLocalizacaoGeofencingInternal(
) {
return {
dentroRaio: false,
avisos: ['Coordenadas inválidas'],
avisos: ['Coordenadas inválidas']
};
}
@@ -500,12 +487,11 @@ async function validarLocalizacaoGeofencingInternal(
const endereco = await ctx.db.get(associacao.enderecoMarcacaoId);
if (!endereco || !endereco.ativo) continue;
const raioMetros =
associacao.raioMetrosPersonalizado ?? endereco.raioMetros;
const raioMetros = associacao.raioMetrosPersonalizado ?? endereco.raioMetros;
enderecosParaValidar.push({
enderecoId: endereco._id,
raioMetros,
raioMetros
});
}
@@ -520,20 +506,18 @@ async function validarLocalizacaoGeofencingInternal(
for (const endereco of enderecosSede) {
enderecosParaValidar.push({
enderecoId: endereco._id,
raioMetros: endereco.raioMetros,
raioMetros: endereco.raioMetros
});
}
}
// Se ainda não houver endereços, usar raio padrão
if (enderecosParaValidar.length === 0) {
avisos.push(
'Nenhum endereço de marcação configurado. Usando validação padrão.'
);
avisos.push('Nenhum endereço de marcação configurado. Usando validação padrão.');
return {
dentroRaio: true, // Não bloquear se não houver configuração
raioUsado: raioPadrao,
avisos,
avisos
};
}
@@ -547,12 +531,7 @@ async function validarLocalizacaoGeofencingInternal(
const endereco = await ctx.db.get(item.enderecoId);
if (!endereco) continue;
const distancia = calcularDistancia(
latitude,
longitude,
endereco.latitude,
endereco.longitude
);
const distancia = calcularDistancia(latitude, longitude, endereco.latitude, endereco.longitude);
if (distanciaMinima === undefined || distancia < distanciaMinima) {
distanciaMinima = distancia;
@@ -565,7 +544,7 @@ async function validarLocalizacaoGeofencingInternal(
if (enderecoMaisProximo === undefined || distanciaMinima === undefined) {
return {
dentroRaio: false,
avisos: ['Não foi possível validar localização'],
avisos: ['Não foi possível validar localização']
};
}
@@ -585,7 +564,7 @@ async function validarLocalizacaoGeofencingInternal(
distanciaMetros: distanciaMinima,
raioUsado: raioUsado ?? raioPadrao,
enderecoEncontrado,
avisos,
avisos
};
}
@@ -598,7 +577,7 @@ export const validarLocalizacaoGeofencing = mutation({
funcionarioId: v.id('funcionarios'),
latitude: v.number(),
longitude: v.number(),
raioPadrao: v.optional(v.number()), // metros - usado se não houver endereços configurados
raioPadrao: v.optional(v.number()) // metros - usado se não houver endereços configurados
},
returns: v.object({
dentroRaio: v.boolean(),
@@ -606,7 +585,7 @@ export const validarLocalizacaoGeofencing = mutation({
distanciaMetros: v.optional(v.number()),
raioUsado: v.optional(v.number()),
enderecoEncontrado: v.optional(v.string()),
avisos: v.array(v.string()),
avisos: v.array(v.string())
}),
handler: async (ctx, args) => {
return await validarLocalizacaoGeofencingInternal(
@@ -616,12 +595,10 @@ export const validarLocalizacaoGeofencing = mutation({
args.longitude,
args.raioPadrao || 100
);
},
}
});
/**
* Exporta a função auxiliar para uso interno em outras mutations
*/
export { validarLocalizacaoGeofencingInternal };

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ import type { Id } from './_generated/dataModel';
export const listarAssociacoesFuncionario = query({
args: {
funcionarioId: v.id('funcionarios'),
incluirInativos: v.optional(v.boolean()),
incluirInativos: v.optional(v.boolean())
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -57,16 +57,14 @@ export const listarAssociacoesFuncionario = query({
pais: endereco.pais,
raioMetros: endereco.raioMetros, // Raio padrão do endereço
tipo: endereco.tipo,
ativo: endereco.ativo,
},
ativo: endereco.ativo
}
};
})
);
return associacoesCompletas.filter(
(item): item is NonNullable<typeof item> => item !== null
);
},
return associacoesCompletas.filter((item): item is NonNullable<typeof item> => item !== null);
}
});
/**
@@ -78,7 +76,7 @@ export const associarEnderecoFuncionario = mutation({
enderecoMarcacaoId: v.id('enderecosMarcacao'),
raioMetrosPersonalizado: v.optional(v.number()),
dataInicio: v.optional(v.string()), // YYYY-MM-DD
dataFim: v.optional(v.string()), // YYYY-MM-DD
dataFim: v.optional(v.string()) // YYYY-MM-DD
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -132,7 +130,7 @@ export const associarEnderecoFuncionario = mutation({
raioMetrosPersonalizado: args.raioMetrosPersonalizado,
dataInicio: args.dataInicio,
dataFim: args.dataFim,
ativo: true,
ativo: true
});
return { associacaoId: associacaoExistente._id, atualizado: true };
@@ -147,11 +145,11 @@ export const associarEnderecoFuncionario = mutation({
dataFim: args.dataFim,
ativo: true,
criadoPor: usuario._id as Id<'usuarios'>,
criadoEm: Date.now(),
criadoEm: Date.now()
});
return { associacaoId, atualizado: false };
},
}
});
/**
@@ -163,7 +161,7 @@ export const atualizarAssociacao = mutation({
raioMetrosPersonalizado: v.optional(v.number()),
dataInicio: v.optional(v.string()),
dataFim: v.optional(v.string()),
ativo: v.optional(v.boolean()),
ativo: v.optional(v.boolean())
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -213,7 +211,7 @@ export const atualizarAssociacao = mutation({
await ctx.db.patch(args.associacaoId, atualizacoes);
return { sucesso: true };
},
}
});
/**
@@ -221,7 +219,7 @@ export const atualizarAssociacao = mutation({
*/
export const removerAssociacao = mutation({
args: {
associacaoId: v.id('funcionarioEnderecosMarcacao'),
associacaoId: v.id('funcionarioEnderecosMarcacao')
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -238,9 +236,9 @@ export const removerAssociacao = mutation({
// Desativar ao invés de deletar
await ctx.db.patch(args.associacaoId, {
ativo: false,
ativo: false
});
return { sucesso: true };
},
}
});

View File

@@ -1,7 +1,7 @@
import { query } from "./_generated/server";
import { query } from './_generated/server';
export const get = query({
handler: async () => {
return "OK";
},
return 'OK';
}
});

View File

@@ -1,74 +1,74 @@
import { httpRouter } from "convex/server";
import { authComponent, createAuth } from "./auth";
import { httpAction } from "./_generated/server";
import { api } from "./_generated/api";
import { getClientIP } from "./utils/getClientIP";
const http = httpRouter();
// Action HTTP para análise de segurança de requisições
// Pode ser chamada do frontend ou de outros sistemas
http.route({
path: "/security/analyze",
method: "POST",
handler: httpAction(async (ctx, request) => {
const url = new URL(request.url);
const method = request.method;
// Extrair IP do cliente
const ipOrigem = getClientIP(request);
// Extrair headers
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
// Extrair query params
const queryParams: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
queryParams[key] = value;
});
// Extrair body se disponível
let body: string | undefined;
try {
body = await request.text();
} catch {
// Ignorar erros ao ler body
}
// Analisar requisição para detectar ataques
const resultado = await ctx.runMutation(api.security.analisarRequisicaoHTTP, {
url: url.pathname + url.search,
method,
headers,
body,
queryParams,
ipOrigem,
userAgent: request.headers.get('user-agent') ?? undefined
});
return new Response(JSON.stringify(resultado), {
status: 200,
headers: { "Content-Type": "application/json" }
});
})
});
// Seed de rate limit para ambiente de desenvolvimento
http.route({
path: "/security/rate-limit/seed-dev",
method: "POST",
handler: httpAction(async (ctx) => {
const resultado = await ctx.runMutation(api.security.seedRateLimitDev, {});
return new Response(JSON.stringify(resultado), {
status: 200,
headers: { "Content-Type": "application/json" }
});
})
});
authComponent.registerRoutes(http, createAuth);
export default http;
import { httpRouter } from 'convex/server';
import { authComponent, createAuth } from './auth';
import { httpAction } from './_generated/server';
import { api } from './_generated/api';
import { getClientIP } from './utils/getClientIP';
const http = httpRouter();
// Action HTTP para análise de segurança de requisições
// Pode ser chamada do frontend ou de outros sistemas
http.route({
path: '/security/analyze',
method: 'POST',
handler: httpAction(async (ctx, request) => {
const url = new URL(request.url);
const method = request.method;
// Extrair IP do cliente
const ipOrigem = getClientIP(request);
// Extrair headers
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
// Extrair query params
const queryParams: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
queryParams[key] = value;
});
// Extrair body se disponível
let body: string | undefined;
try {
body = await request.text();
} catch {
// Ignorar erros ao ler body
}
// Analisar requisição para detectar ataques
const resultado = await ctx.runMutation(api.security.analisarRequisicaoHTTP, {
url: url.pathname + url.search,
method,
headers,
body,
queryParams,
ipOrigem,
userAgent: request.headers.get('user-agent') ?? undefined
});
return new Response(JSON.stringify(resultado), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
})
});
// Seed de rate limit para ambiente de desenvolvimento
http.route({
path: '/security/rate-limit/seed-dev',
method: 'POST',
handler: httpAction(async (ctx) => {
const resultado = await ctx.runMutation(api.security.seedRateLimitDev, {});
return new Response(JSON.stringify(resultado), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
})
});
authComponent.registerRoutes(http, createAuth);
export default http;

View File

@@ -336,7 +336,9 @@ export const listarMinhasSolicitacoes = query({
arquivoResposta: s.arquivoResposta ? s.arquivoResposta.toString() : null
}));
console.log(`[listarMinhasSolicitacoes] Usuário: ${usuario._id}, Solicitações encontradas: ${resultado.length}`);
console.log(
`[listarMinhasSolicitacoes] Usuário: ${usuario._id}, Solicitações encontradas: ${resultado.length}`
);
return resultado;
} catch (error) {
console.error('[listarMinhasSolicitacoes] Erro ao listar minhas solicitações:', error);
@@ -544,11 +546,7 @@ export const responderSolicitacao = mutation({
args: {
solicitacaoId: v.id('solicitacoesLGPD'),
resposta: v.string(),
status: v.union(
v.literal('concluida'),
v.literal('rejeitada'),
v.literal('em_analise')
),
status: v.union(v.literal('concluida'), v.literal('rejeitada'), v.literal('em_analise')),
arquivoResposta: v.optional(v.id('_storage'))
},
returns: v.object({ sucesso: v.boolean() }),
@@ -895,9 +893,12 @@ export const atualizarConfiguracaoLGPD = mutation({
await ctx.db.insert('configuracaoLGPD', {
encarregadoNome: args.encarregadoNome ?? valoresAtuais.encarregadoNome ?? undefined,
encarregadoEmail: args.encarregadoEmail ?? valoresAtuais.encarregadoEmail ?? undefined,
encarregadoTelefone: args.encarregadoTelefone ?? valoresAtuais.encarregadoTelefone ?? undefined,
encarregadoTelefone:
args.encarregadoTelefone ?? valoresAtuais.encarregadoTelefone ?? undefined,
encarregadoHorarioAtendimento:
args.encarregadoHorarioAtendimento ?? valoresAtuais.encarregadoHorarioAtendimento ?? undefined,
args.encarregadoHorarioAtendimento ??
valoresAtuais.encarregadoHorarioAtendimento ??
undefined,
prazoRespostaPadrao: args.prazoRespostaPadrao ?? valoresAtuais.prazoRespostaPadrao,
diasAlertaVencimento: args.diasAlertaVencimento ?? valoresAtuais.diasAlertaVencimento,
termoObrigatorio: args.termoObrigatorio ?? valoresAtuais.termoObrigatorio ?? false,
@@ -944,11 +945,10 @@ export const obterEstatisticasLGPD = query({
const agora = Date.now();
const tresDias = 3 * 24 * 60 * 60 * 1000;
const solicitacoesVencendo = solicitacoes.filter(
(s) =>
s.status === 'pendente' || s.status === 'em_analise'
? s.prazoResposta - agora <= tresDias && s.prazoResposta > agora
: false
const solicitacoesVencendo = solicitacoes.filter((s) =>
s.status === 'pendente' || s.status === 'em_analise'
? s.prazoResposta - agora <= tresDias && s.prazoResposta > agora
: false
).length;
const solicitacoesPorTipo: Record<string, number> = {};
@@ -956,9 +956,7 @@ export const obterEstatisticasLGPD = query({
solicitacoesPorTipo[s.tipo] = (solicitacoesPorTipo[s.tipo] || 0) + 1;
});
const consentimentosAtivos = consentimentos.filter(
(c) => c.aceito && !c.revogadoEm
).length;
const consentimentosAtivos = consentimentos.filter((c) => c.aceito && !c.revogadoEm).length;
return {
totalSolicitacoes: solicitacoes.length,
@@ -972,4 +970,3 @@ export const obterEstatisticasLGPD = query({
};
}
});

View File

@@ -1,232 +1,219 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
/**
* Listar logs de acesso com filtros
*/
export const listar = query({
args: {
usuarioId: v.optional(v.id("usuarios")),
tipo: v.optional(
v.union(
v.literal("login"),
v.literal("logout"),
v.literal("acesso_negado"),
v.literal("senha_alterada"),
v.literal("sessao_expirada")
)
),
dataInicio: v.optional(v.number()),
dataFim: v.optional(v.number()),
limite: v.optional(v.number()),
},
returns: v.array(
v.object({
_id: v.id("logsAcesso"),
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(),
usuario: v.optional(
v.object({
_id: v.id("usuarios"),
matricula: v.string(),
nome: v.string(),
})
),
})
),
handler: async (ctx, args) => {
let logs;
args: {
usuarioId: v.optional(v.id('usuarios')),
tipo: v.optional(
v.union(
v.literal('login'),
v.literal('logout'),
v.literal('acesso_negado'),
v.literal('senha_alterada'),
v.literal('sessao_expirada')
)
),
dataInicio: v.optional(v.number()),
dataFim: v.optional(v.number()),
limite: v.optional(v.number())
},
returns: v.array(
v.object({
_id: v.id('logsAcesso'),
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(),
usuario: v.optional(
v.object({
_id: v.id('usuarios'),
matricula: v.string(),
nome: v.string()
})
)
})
),
handler: async (ctx, args) => {
let logs;
// Filtrar por usuário
if (args.usuarioId !== undefined) {
const usuarioId = args.usuarioId; // TypeScript agora sabe que não é undefined
logs = await ctx.db
.query("logsAcesso")
.withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioId))
.collect();
} else {
logs = await ctx.db
.query("logsAcesso")
.withIndex("by_timestamp")
.collect();
}
// Filtrar por usuário
if (args.usuarioId !== undefined) {
const usuarioId = args.usuarioId; // TypeScript agora sabe que não é undefined
logs = await ctx.db
.query('logsAcesso')
.withIndex('by_usuario', (q) => q.eq('usuarioId', usuarioId))
.collect();
} else {
logs = await ctx.db.query('logsAcesso').withIndex('by_timestamp').collect();
}
// Filtrar por tipo
if (args.tipo) {
logs = logs.filter((log) => log.tipo === args.tipo);
}
// Filtrar por tipo
if (args.tipo) {
logs = logs.filter((log) => log.tipo === args.tipo);
}
// Filtrar por data
if (args.dataInicio) {
logs = logs.filter((log) => log.timestamp >= args.dataInicio!);
}
if (args.dataFim) {
logs = logs.filter((log) => log.timestamp <= args.dataFim!);
}
// Filtrar por data
if (args.dataInicio) {
logs = logs.filter((log) => log.timestamp >= args.dataInicio!);
}
if (args.dataFim) {
logs = logs.filter((log) => log.timestamp <= args.dataFim!);
}
// Ordenar por timestamp decrescente
logs.sort((a, b) => b.timestamp - a.timestamp);
// Ordenar por timestamp decrescente
logs.sort((a, b) => b.timestamp - a.timestamp);
// Limitar resultados
if (args.limite) {
logs = logs.slice(0, args.limite);
}
// Limitar resultados
if (args.limite) {
logs = logs.slice(0, args.limite);
}
// Buscar informações dos usuários
const resultado = [];
for (const log of logs) {
let usuario = undefined;
if (log.usuarioId) {
const user = await ctx.db.get(log.usuarioId);
if (user) {
let matricula: string | undefined = undefined;
if (user.funcionarioId) {
const funcionario = await ctx.db.get(user.funcionarioId);
matricula = funcionario?.matricula;
}
usuario = {
_id: user._id,
matricula: matricula || "",
nome: user.nome,
};
}
}
// Buscar informações dos usuários
const resultado = [];
for (const log of logs) {
let usuario = undefined;
if (log.usuarioId) {
const user = await ctx.db.get(log.usuarioId);
if (user) {
let matricula: string | undefined = undefined;
if (user.funcionarioId) {
const funcionario = await ctx.db.get(user.funcionarioId);
matricula = funcionario?.matricula;
}
usuario = {
_id: user._id,
matricula: matricula || '',
nome: user.nome
};
}
}
resultado.push({
_id: log._id,
tipo: log.tipo,
ipAddress: log.ipAddress,
userAgent: log.userAgent,
detalhes: log.detalhes,
timestamp: log.timestamp,
usuario,
});
}
resultado.push({
_id: log._id,
tipo: log.tipo,
ipAddress: log.ipAddress,
userAgent: log.userAgent,
detalhes: log.detalhes,
timestamp: log.timestamp,
usuario
});
}
return resultado;
},
return resultado;
}
});
/**
* Obter estatísticas de acessos
*/
export const estatisticas = query({
args: {
dataInicio: v.optional(v.number()),
dataFim: v.optional(v.number()),
},
returns: v.object({
totalLogins: v.number(),
totalLogouts: v.number(),
totalAcessosNegados: v.number(),
totalSenhasAlteradas: v.number(),
totalSessoesExpiradas: v.number(),
loginsPorDia: v.array(
v.object({
data: v.string(),
quantidade: v.number(),
})
),
}),
handler: async (ctx, args) => {
let logs = await ctx.db.query("logsAcesso").collect();
args: {
dataInicio: v.optional(v.number()),
dataFim: v.optional(v.number())
},
returns: v.object({
totalLogins: v.number(),
totalLogouts: v.number(),
totalAcessosNegados: v.number(),
totalSenhasAlteradas: v.number(),
totalSessoesExpiradas: v.number(),
loginsPorDia: v.array(
v.object({
data: v.string(),
quantidade: v.number()
})
)
}),
handler: async (ctx, args) => {
let logs = await ctx.db.query('logsAcesso').collect();
// Filtrar por data
if (args.dataInicio) {
logs = logs.filter((log) => log.timestamp >= args.dataInicio!);
}
if (args.dataFim) {
logs = logs.filter((log) => log.timestamp <= args.dataFim!);
}
// Filtrar por data
if (args.dataInicio) {
logs = logs.filter((log) => log.timestamp >= args.dataInicio!);
}
if (args.dataFim) {
logs = logs.filter((log) => log.timestamp <= args.dataFim!);
}
// Contar por tipo
const totalLogins = logs.filter((log) => log.tipo === "login").length;
const totalLogouts = logs.filter((log) => log.tipo === "logout").length;
const totalAcessosNegados = logs.filter(
(log) => log.tipo === "acesso_negado"
).length;
const totalSenhasAlteradas = logs.filter(
(log) => log.tipo === "senha_alterada"
).length;
const totalSessoesExpiradas = logs.filter(
(log) => log.tipo === "sessao_expirada"
).length;
// Contar por tipo
const totalLogins = logs.filter((log) => log.tipo === 'login').length;
const totalLogouts = logs.filter((log) => log.tipo === 'logout').length;
const totalAcessosNegados = logs.filter((log) => log.tipo === 'acesso_negado').length;
const totalSenhasAlteradas = logs.filter((log) => log.tipo === 'senha_alterada').length;
const totalSessoesExpiradas = logs.filter((log) => log.tipo === 'sessao_expirada').length;
// Agrupar logins por dia
const loginsPorDiaMap = new Map<string, number>();
const loginsOnly = logs.filter((log) => log.tipo === "login");
// Agrupar logins por dia
const loginsPorDiaMap = new Map<string, number>();
const loginsOnly = logs.filter((log) => log.tipo === 'login');
for (const log of loginsOnly) {
const data = new Date(log.timestamp).toISOString().split("T")[0];
loginsPorDiaMap.set(data, (loginsPorDiaMap.get(data) || 0) + 1);
}
for (const log of loginsOnly) {
const data = new Date(log.timestamp).toISOString().split('T')[0];
loginsPorDiaMap.set(data, (loginsPorDiaMap.get(data) || 0) + 1);
}
const loginsPorDia = Array.from(loginsPorDiaMap.entries())
.map(([data, quantidade]) => ({ data, quantidade }))
.sort((a, b) => a.data.localeCompare(b.data));
const loginsPorDia = Array.from(loginsPorDiaMap.entries())
.map(([data, quantidade]) => ({ data, quantidade }))
.sort((a, b) => a.data.localeCompare(b.data));
return {
totalLogins,
totalLogouts,
totalAcessosNegados,
totalSenhasAlteradas,
totalSessoesExpiradas,
loginsPorDia,
};
},
return {
totalLogins,
totalLogouts,
totalAcessosNegados,
totalSenhasAlteradas,
totalSessoesExpiradas,
loginsPorDia
};
}
});
/**
* Limpar logs antigos (apenas TI)
*/
export const limpar = mutation({
args: {
dataLimite: v.number(), // Excluir logs anteriores a esta data
},
returns: v.object({
excluidos: v.number(),
}),
handler: async (ctx, args) => {
const logs = await ctx.db
.query("logsAcesso")
.withIndex("by_timestamp")
.collect();
args: {
dataLimite: v.number() // Excluir logs anteriores a esta data
},
returns: v.object({
excluidos: v.number()
}),
handler: async (ctx, args) => {
const logs = await ctx.db.query('logsAcesso').withIndex('by_timestamp').collect();
const logsAntigos = logs.filter((log) => log.timestamp < args.dataLimite);
const logsAntigos = logs.filter((log) => log.timestamp < args.dataLimite);
for (const log of logsAntigos) {
await ctx.db.delete(log._id);
}
for (const log of logsAntigos) {
await ctx.db.delete(log._id);
}
return { excluidos: logsAntigos.length };
},
return { excluidos: logsAntigos.length };
}
});
/**
* Limpar todos os logs (apenas TI)
*/
export const limparTodos = mutation({
args: {},
returns: v.object({
excluidos: v.number(),
}),
handler: async (ctx) => {
const logs = await ctx.db.query("logsAcesso").collect();
args: {},
returns: v.object({
excluidos: v.number()
}),
handler: async (ctx) => {
const logs = await ctx.db.query('logsAcesso').collect();
for (const log of logs) {
await ctx.db.delete(log._id);
}
for (const log of logs) {
await ctx.db.delete(log._id);
}
return { excluidos: logs.length };
},
return { excluidos: logs.length };
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -625,20 +625,15 @@ export const getStatusSistema = query({
}
// Total de registros (estimativa baseada em tabelas principais)
const [usuarios, funcionarios, simbolos, alertas, metricas] =
await Promise.all([
ctx.db.query('usuarios').collect(),
ctx.db.query('funcionarios').collect(),
ctx.db.query('simbolos').collect(),
ctx.db.query('alertConfigurations').collect(),
ctx.db.query('systemMetrics').take(100) // não precisa contar tudo
]);
const [usuarios, funcionarios, simbolos, alertas, metricas] = await Promise.all([
ctx.db.query('usuarios').collect(),
ctx.db.query('funcionarios').collect(),
ctx.db.query('simbolos').collect(),
ctx.db.query('alertConfigurations').collect(),
ctx.db.query('systemMetrics').take(100) // não precisa contar tudo
]);
const totalRegistros =
usuarios.length +
funcionarios.length +
simbolos.length +
alertas.length +
metricas.length;
usuarios.length + funcionarios.length + simbolos.length + alertas.length + metricas.length;
// Métricas de performance com fallbacks seguros
const tempoMedioResposta = ultimaMetrica?.tempoRespostaMedio ?? 0;
@@ -703,18 +698,18 @@ export const getAtividadeBancoDados = query({
for (let i = 0; i < numBuckets; i++) {
const inicio = haUmMinuto + i * bucketSizeMs;
const fim = inicio + bucketSizeMs;
// Contar atividades de criação/inserção (entradas)
const atividadesBucket = atividadesRecentes.filter(
(a) => a.timestamp >= inicio && a.timestamp < fim
);
const entradasAtividades = atividadesBucket.filter(
a => a.acao === 'criar' || a.acao === 'inserir' || a.acao === 'cadastrar'
(a) => a.acao === 'criar' || a.acao === 'inserir' || a.acao === 'cadastrar'
).length;
// Contar atividades de exclusão/remoção (saídas)
const saidasAtividades = atividadesBucket.filter(
a => a.acao === 'excluir' || a.acao === 'remover' || a.acao === 'deletar'
(a) => a.acao === 'excluir' || a.acao === 'remover' || a.acao === 'deletar'
).length;
// Usar mensagensPorMinuto como adicional se disponível
@@ -748,7 +743,7 @@ export const getDistribuicaoRequisicoes = query({
}),
handler: async (ctx) => {
const umaHoraAtras = Date.now() - 60 * 60 * 1000;
// Buscar atividades reais do sistema
const atividades = await ctx.db
.query('logsAtividades')
@@ -764,14 +759,24 @@ export const getDistribuicaoRequisicoes = query({
// Contar operações de leitura (consultas, visualizações)
const leituras = atividades.filter(
a => a.acao === 'consultar' || a.acao === 'visualizar' || a.acao === 'listar' || a.acao === 'buscar'
(a) =>
a.acao === 'consultar' ||
a.acao === 'visualizar' ||
a.acao === 'listar' ||
a.acao === 'buscar'
).length;
// Contar operações de escrita (criar, editar, excluir)
const escritas = atividades.filter(
a => a.acao === 'criar' || a.acao === 'editar' || a.acao === 'excluir' ||
a.acao === 'inserir' || a.acao === 'atualizar' || a.acao === 'deletar' ||
a.acao === 'cadastrar' || a.acao === 'remover'
(a) =>
a.acao === 'criar' ||
a.acao === 'editar' ||
a.acao === 'excluir' ||
a.acao === 'inserir' ||
a.acao === 'atualizar' ||
a.acao === 'deletar' ||
a.acao === 'cadastrar' ||
a.acao === 'remover'
).length;
// Adicionar estimativa baseada em mensagens se disponível
@@ -782,7 +787,7 @@ export const getDistribuicaoRequisicoes = query({
// Queries são leituras + parte das mensagens (como consultas de chat)
const queries = leituras + Math.round(totalMensagens * 0.5);
// Mutations são escritas + parte das mensagens (como envio de mensagens)
const mutations = escritas + Math.round(totalMensagens * 0.3);

View File

@@ -9,12 +9,7 @@ import { validarLocalizacaoGeofencingInternal } from './enderecosMarcacao';
* Calcula distância entre duas coordenadas (fórmula de Haversine)
* Retorna distância em metros
*/
function calcularDistancia(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number {
function calcularDistancia(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000; // Raio da Terra em metros
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
@@ -127,12 +122,7 @@ async function validarLocalizacao(
if (ipAddress) {
const ipGeo = await obterGeoPorIP(ipAddress);
if (ipGeo) {
distanciaIPvsGPS = calcularDistancia(
latitude,
longitude,
ipGeo.latitude,
ipGeo.longitude
);
distanciaIPvsGPS = calcularDistancia(latitude, longitude, ipGeo.latitude, ipGeo.longitude);
// Se diferença > 50km, muito suspeito
if (distanciaIPvsGPS > 50000) {
@@ -176,7 +166,7 @@ async function validarLocalizacao(
// Calcular velocidade (km/h) se tempo decorrido > 0
if (tempoDecorridoHoras > 0 && tempoDecorridoHoras < 24) {
velocidadeUltimoRegistro = (distanciaUltimoRegistro / 1000) / tempoDecorridoHoras; // km/h
velocidadeUltimoRegistro = distanciaUltimoRegistro / 1000 / tempoDecorridoHoras; // km/h
// Se velocidade > 1000 km/h, impossível (mais rápido que avião)
if (velocidadeUltimoRegistro > 1000) {
@@ -280,19 +270,34 @@ function validarAcelerometro(
// Se há dados de acelerômetro, validar
if (acelerometroX !== undefined && acelerometroY !== undefined && acelerometroZ !== undefined) {
// Verificar se valores são realistas (aceleração geralmente entre -20 e +20 m/s² em uso normal)
const magnitude = magnitudeMovimento || Math.sqrt(acelerometroX * acelerometroX + acelerometroY * acelerometroY + acelerometroZ * acelerometroZ);
const magnitude =
magnitudeMovimento ||
Math.sqrt(
acelerometroX * acelerometroX +
acelerometroY * acelerometroY +
acelerometroZ * acelerometroZ
);
if (magnitude > 50) {
// Aceleração muito alta pode indicar leitura errada ou emulador
scoreConfianca *= 0.6;
avisos.push(`Magnitude de movimento muito alta (${magnitude.toFixed(2)} m/s²). Pode indicar leitura incorreta.`);
avisos.push(
`Magnitude de movimento muito alta (${magnitude.toFixed(2)} m/s²). Pode indicar leitura incorreta.`
);
}
// Se não há movimento detectado quando deveria haver (em móvel), pode ser suspeito
if (isDesktop !== true && movimentoDetectado === false && variacaoAcelerometro !== undefined && variacaoAcelerometro < 0.001) {
if (
isDesktop !== true &&
movimentoDetectado === false &&
variacaoAcelerometro !== undefined &&
variacaoAcelerometro < 0.001
) {
// Variância muito baixa pode indicar que o dispositivo está parado ou emulador
scoreConfianca *= 0.9;
avisos.push('Nenhum movimento detectado durante o registro. Pode ser normal se o dispositivo estava parado.');
avisos.push(
'Nenhum movimento detectado durante o registro. Pode ser normal se o dispositivo estava parado.'
);
}
// Se há movimento, aumenta confiança
@@ -320,7 +325,7 @@ export const generateUploadUrl = mutation({
throw new Error('Usuário não autenticado');
}
return await ctx.storage.generateUploadUrl();
},
}
});
/**
@@ -434,21 +439,21 @@ export const registrarPonto = mutation({
movimentoDetectado: v.boolean(),
magnitude: v.number(),
variacao: v.number(),
timestamp: v.number(),
timestamp: v.number()
})
),
giroscopio: v.optional(
v.object({
alpha: v.number(),
beta: v.number(),
gamma: v.number(),
gamma: v.number()
})
),
)
})
),
timestamp: v.number(),
sincronizadoComServidor: v.boolean(),
justificativa: v.optional(v.string()),
justificativa: v.optional(v.string())
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -487,7 +492,7 @@ export const registrarPonto = mutation({
const hora = dataObj.getUTCHours();
const minuto = dataObj.getUTCMinutes();
const segundo = dataObj.getUTCSeconds();
// Obter data no formato YYYY-MM-DD usando UTC
const ano = dataObj.getUTCFullYear();
const mes = String(dataObj.getUTCMonth() + 1).padStart(2, '0');
@@ -498,12 +503,12 @@ export const registrarPonto = mutation({
const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined
const registrosMinuto = await ctx.db
.query('registrosPonto')
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data))
.withIndex('by_funcionario_data', (q) =>
q.eq('funcionarioId', funcionarioId).eq('data', data)
)
.collect();
const registroDuplicado = registrosMinuto.find(
(r) => r.hora === hora && r.minuto === minuto
);
const registroDuplicado = registrosMinuto.find((r) => r.hora === hora && r.minuto === minuto);
if (registroDuplicado) {
throw new Error('Já existe um registro neste minuto');
@@ -553,7 +558,7 @@ export const registrarPonto = mutation({
if (agora.getTime() > dataFimTimestamp && !dispensa.isento) {
// Desativar dispensa expirada (mutation pode fazer isso)
await ctx.db.patch(dispensa._id, {
ativo: false,
ativo: false
});
}
}
@@ -578,7 +583,12 @@ export const registrarPonto = mutation({
break;
}
const dentroDoPrazo = calcularStatusPonto(hora, minuto, horarioConfigurado, config.toleranciaMinutos);
const dentroDoPrazo = calcularStatusPonto(
hora,
minuto,
horarioConfigurado,
config.toleranciaMinutos
);
// Validar localização se fornecida e salvar informações detalhadas
let validacaoLocalizacao: {
@@ -592,10 +602,7 @@ export const registrarPonto = mutation({
tempoDecorridoHoras?: number;
} | null = null;
if (
args.informacoesDispositivo?.latitude &&
args.informacoesDispositivo?.longitude
) {
if (args.informacoesDispositivo?.latitude && args.informacoesDispositivo?.longitude) {
validacaoLocalizacao = await validarLocalizacao(
ctx,
usuario.funcionarioId,
@@ -612,16 +619,19 @@ export const registrarPonto = mutation({
const baixaConfianca = validacaoLocalizacao.scoreConfianca < 0.5;
if (suspeitaFrontend || suspeitaBackend || baixaConfianca) {
console.warn('⚠️ LOCALIZAÇÃO COM BAIXA CONFIABILIDADE DETECTADA (registrando normalmente):', {
funcionarioId: usuario.funcionarioId,
latitude: args.informacoesDispositivo.latitude,
longitude: args.informacoesDispositivo.longitude,
confiabilidadeGPSFrontend: args.informacoesDispositivo.confiabilidadeGPS,
scoreConfiancaBackend: validacaoLocalizacao.scoreConfianca,
suspeitaFrontend: suspeitaFrontend ? args.informacoesDispositivo.motivoSuspeita : null,
suspeitaBackend: suspeitaBackend ? validacaoLocalizacao.motivo : null,
avisos: validacaoLocalizacao.avisos
});
console.warn(
'⚠️ LOCALIZAÇÃO COM BAIXA CONFIABILIDADE DETECTADA (registrando normalmente):',
{
funcionarioId: usuario.funcionarioId,
latitude: args.informacoesDispositivo.latitude,
longitude: args.informacoesDispositivo.longitude,
confiabilidadeGPSFrontend: args.informacoesDispositivo.confiabilidadeGPS,
scoreConfiancaBackend: validacaoLocalizacao.scoreConfianca,
suspeitaFrontend: suspeitaFrontend ? args.informacoesDispositivo.motivoSuspeita : null,
suspeitaBackend: suspeitaBackend ? validacaoLocalizacao.motivo : null,
avisos: validacaoLocalizacao.avisos
}
);
}
}
@@ -656,17 +666,14 @@ export const registrarPonto = mutation({
validacaoLocalizacao = {
valida: true,
scoreConfianca: 1,
avisos: [],
avisos: []
};
}
validacaoLocalizacao.avisos.push(...geofencing.avisos);
// Reduzir score de confiança se estiver fora do raio
if (!geofencing.dentroRaio) {
validacaoLocalizacao.scoreConfianca = Math.min(
validacaoLocalizacao.scoreConfianca,
0.7
);
validacaoLocalizacao.scoreConfianca = Math.min(validacaoLocalizacao.scoreConfianca, 0.7);
}
}
}
@@ -697,7 +704,8 @@ export const registrarPonto = mutation({
let scoreFinalConfianca = 1.0;
if (validacaoLocalizacao && validacaoAcelerometro) {
// GPS tem peso 0.7, acelerômetro tem peso 0.3
scoreFinalConfianca = (validacaoLocalizacao.scoreConfianca * 0.7) + (validacaoAcelerometro.scoreConfianca * 0.3);
scoreFinalConfianca =
validacaoLocalizacao.scoreConfianca * 0.7 + validacaoAcelerometro.scoreConfianca * 0.3;
} else if (validacaoLocalizacao) {
scoreFinalConfianca = validacaoLocalizacao.scoreConfianca;
} else if (validacaoAcelerometro) {
@@ -738,8 +746,19 @@ export const registrarPonto = mutation({
speed: args.informacoesDispositivo?.speed,
confiabilidadeGPS: args.informacoesDispositivo?.confiabilidadeGPS,
scoreConfiancaBackend: scoreFinalConfianca,
suspeitaSpoofing: args.informacoesDispositivo?.suspeitaSpoofing || (validacaoLocalizacao ? validacaoLocalizacao.scoreConfianca < 0.5 || !validacaoLocalizacao.valida : undefined) || (validacaoAcelerometro ? validacaoAcelerometro.scoreConfianca < 0.5 || !validacaoAcelerometro.valida : undefined),
motivoSuspeita: args.informacoesDispositivo?.motivoSuspeita || validacaoLocalizacao?.motivo || validacaoAcelerometro?.motivo || (todosAvisos.length > 0 ? todosAvisos.join('; ') : undefined),
suspeitaSpoofing:
args.informacoesDispositivo?.suspeitaSpoofing ||
(validacaoLocalizacao
? validacaoLocalizacao.scoreConfianca < 0.5 || !validacaoLocalizacao.valida
: undefined) ||
(validacaoAcelerometro
? validacaoAcelerometro.scoreConfianca < 0.5 || !validacaoAcelerometro.valida
: undefined),
motivoSuspeita:
args.informacoesDispositivo?.motivoSuspeita ||
validacaoLocalizacao?.motivo ||
validacaoAcelerometro?.motivo ||
(todosAvisos.length > 0 ? todosAvisos.join('; ') : undefined),
// Informações detalhadas de validação (sempre salvar quando houver validação)
avisosValidacao: todosAvisos.length > 0 ? todosAvisos : undefined,
// Informações de Geofencing
@@ -775,14 +794,14 @@ export const registrarPonto = mutation({
giroscopioGamma: args.informacoesDispositivo?.giroscopio?.gamma,
sensorDisponivel: args.informacoesDispositivo?.sensorDisponivel,
permissaoSensorNegada: args.informacoesDispositivo?.permissaoNegada,
criadoEm: Date.now(),
criadoEm: Date.now()
});
// Atualizar banco de horas após registrar
await atualizarBancoHoras(ctx, usuario.funcionarioId, data, config);
return { registroId, tipo, dentroDoPrazo };
},
}
});
/**
@@ -791,7 +810,7 @@ export const registrarPonto = mutation({
export const listarRegistrosDia = query({
args: {
data: v.optional(v.string()), // YYYY-MM-DD, se não fornecido usa hoje
_refresh: v.optional(v.number()), // Parâmetro usado pelo frontend para forçar refresh
_refresh: v.optional(v.number()) // Parâmetro usado pelo frontend para forçar refresh
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -806,20 +825,26 @@ export const listarRegistrosDia = query({
const registros = await ctx.db
.query('registrosPonto')
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data))
.withIndex('by_funcionario_data', (q) =>
q.eq('funcionarioId', funcionarioId).eq('data', data)
)
.order('asc')
.collect();
console.log('[listarRegistrosDia] Registros encontrados:', registros.length, registros.map(r => ({
_id: r._id,
tipo: r.tipo,
data: r.data,
hora: r.hora,
minuto: r.minuto
})));
console.log(
'[listarRegistrosDia] Registros encontrados:',
registros.length,
registros.map((r) => ({
_id: r._id,
tipo: r.tipo,
data: r.data,
hora: r.hora,
minuto: r.minuto
}))
);
return registros;
},
}
});
/**
@@ -828,7 +853,7 @@ export const listarRegistrosDia = query({
export const obterSaldoDiario = query({
args: {
funcionarioId: v.id('funcionarios'),
data: v.string(), // YYYY-MM-DD
data: v.string() // YYYY-MM-DD
},
handler: async (ctx, args) => {
// Buscar banco de horas do dia
@@ -844,7 +869,7 @@ export const obterSaldoDiario = query({
saldoMinutos: 0,
horas: 0,
minutos: 0,
positivo: true,
positivo: true
};
}
@@ -856,9 +881,9 @@ export const obterSaldoDiario = query({
saldoMinutos: bancoHoras.saldoMinutos,
horas,
minutos,
positivo,
positivo
};
},
}
});
/**
@@ -868,7 +893,7 @@ export const listarRegistrosPeriodo = query({
args: {
funcionarioId: v.optional(v.id('funcionarios')),
dataInicio: v.string(), // YYYY-MM-DD
dataFim: v.string(), // YYYY-MM-DD
dataFim: v.string() // YYYY-MM-DD
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -890,9 +915,9 @@ export const listarRegistrosPeriodo = query({
// Validar formato YYYY-MM-DD
const dataInicioRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dataInicioRegex.test(args.dataInicio) || !dataInicioRegex.test(args.dataFim)) {
console.warn('[listarRegistrosPeriodo] Formato de data inválido', {
dataInicio: args.dataInicio,
dataFim: args.dataFim
console.warn('[listarRegistrosPeriodo] Formato de data inválido', {
dataInicio: args.dataInicio,
dataFim: args.dataFim
});
return [];
}
@@ -905,18 +930,18 @@ export const listarRegistrosPeriodo = query({
});
let registrosFiltrados;
// Se funcionário foi especificado, usar índice por funcionário e data (mais eficiente)
if (args.funcionarioId) {
// Garantir que funcionarioId não é undefined para TypeScript
const funcionarioId = args.funcionarioId;
// Buscar todos os registros do funcionário
const todosRegistrosFuncionario = await ctx.db
.query('registrosPonto')
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId))
.collect();
// Filtrar por período de data usando comparação de strings (formato YYYY-MM-DD)
registrosFiltrados = todosRegistrosFuncionario.filter((r) => {
// Comparação de strings funciona para formato YYYY-MM-DD
@@ -928,47 +953,47 @@ export const listarRegistrosPeriodo = query({
// Tentar usar índice por data primeiro
const registros = await ctx.db
.query('registrosPonto')
.withIndex('by_data', (q) =>
q.gte('data', args.dataInicio).lte('data', args.dataFim)
)
.withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim))
.collect();
console.log('[listarRegistrosPeriodo] Registros do índice by_data:', registros.length);
// Garantir que as datas estão no formato correto e filtrar novamente para garantir
registrosFiltrados = registros.filter((r) => {
// Comparação de strings funciona para formato YYYY-MM-DD
return r.data >= args.dataInicio && r.data <= args.dataFim;
});
console.log('[listarRegistrosPeriodo] Registros após filtro:', registrosFiltrados.length);
} catch (error) {
console.error('[listarRegistrosPeriodo] Erro ao buscar registros:', error);
// Fallback: buscar todos e filtrar manualmente
const todosRegistros = await ctx.db
.query('registrosPonto')
.collect();
const todosRegistros = await ctx.db.query('registrosPonto').collect();
registrosFiltrados = todosRegistros.filter((r) => {
return r.data >= args.dataInicio && r.data <= args.dataFim;
});
console.log('[listarRegistrosPeriodo] Fallback - registros encontrados:', registrosFiltrados.length);
console.log(
'[listarRegistrosPeriodo] Fallback - registros encontrados:',
registrosFiltrados.length
);
}
}
console.log('[listarRegistrosPeriodo] Registros encontrados antes de buscar funcionários:', registrosFiltrados.length);
console.log(
'[listarRegistrosPeriodo] Registros encontrados antes de buscar funcionários:',
registrosFiltrados.length
);
// Buscar informações dos funcionários
const funcionariosIds = new Set(registrosFiltrados.map((r) => r.funcionarioId));
const funcionarios = await Promise.all(
Array.from(funcionariosIds).map((id) => ctx.db.get(id))
);
const funcionarios = await Promise.all(Array.from(funcionariosIds).map((id) => ctx.db.get(id)));
// Buscar saldos diários para cada data/funcionário
const saldosPorDataFuncionario: Record<string, number> = {};
const datasUnicas = new Set(registrosFiltrados.map((r) => `${r.funcionarioId}-${r.data}`));
for (const chave of datasUnicas) {
const [funcId, data] = chave.split('-');
const bancoHoras = await ctx.db
@@ -977,19 +1002,22 @@ export const listarRegistrosPeriodo = query({
q.eq('funcionarioId', funcId as Id<'funcionarios'>).eq('data', data)
)
.first();
if (bancoHoras) {
saldosPorDataFuncionario[chave] = bancoHoras.saldoMinutos;
}
}
console.log('[listarRegistrosPeriodo] Total de registros a retornar:', registrosFiltrados.length);
console.log(
'[listarRegistrosPeriodo] Total de registros a retornar:',
registrosFiltrados.length
);
// Buscar fotos de perfil dos funcionários
const funcionariosComFoto = await Promise.all(
funcionarios.map(async (funcionario) => {
if (!funcionario) return { funcionario: null, fotoPerfilUrl: null };
let fotoPerfilUrl: string | null = null;
const usuario = await ctx.db
.query('usuarios')
@@ -998,13 +1026,15 @@ export const listarRegistrosPeriodo = query({
if (usuario?.fotoPerfil) {
fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil);
}
return { funcionario, fotoPerfilUrl };
})
);
return registrosFiltrados.map((registro) => {
const funcionarioComFoto = funcionariosComFoto.find((f) => f.funcionario?._id === registro.funcionarioId);
const funcionarioComFoto = funcionariosComFoto.find(
(f) => f.funcionario?._id === registro.funcionarioId
);
const funcionario = funcionarioComFoto?.funcionario;
const fotoPerfilUrl = funcionarioComFoto?.fotoPerfilUrl || null;
const chave = `${registro.funcionarioId}-${registro.data}`;
@@ -1019,7 +1049,7 @@ export const listarRegistrosPeriodo = query({
? {
nome: funcionario.nome,
matricula: funcionario.matricula,
descricaoCargo: funcionario.descricaoCargo,
descricaoCargo: funcionario.descricaoCargo
}
: null,
fotoPerfilUrl,
@@ -1027,11 +1057,11 @@ export const listarRegistrosPeriodo = query({
saldoMinutos,
horas,
minutos,
positivo,
},
positivo
}
};
});
},
}
});
/**
@@ -1041,7 +1071,7 @@ export const obterEstatisticas = query({
args: {
dataInicio: v.string(), // YYYY-MM-DD
dataFim: v.string(), // YYYY-MM-DD
funcionarioId: v.optional(v.id('funcionarios')),
funcionarioId: v.optional(v.id('funcionarios'))
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -1053,7 +1083,7 @@ export const obterEstatisticas = query({
foraDoPrazo: 0,
totalFuncionarios: 0,
funcionariosDentroPrazo: 0,
funcionariosForaPrazo: 0,
funcionariosForaPrazo: 0
};
}
@@ -1091,9 +1121,9 @@ export const obterEstatisticas = query({
foraDoPrazo,
totalFuncionarios,
funcionariosDentroPrazo,
funcionariosForaPrazo,
funcionariosForaPrazo
};
},
}
});
/**
@@ -1101,7 +1131,7 @@ export const obterEstatisticas = query({
*/
export const obterRegistro = query({
args: {
registroId: v.id('registrosPonto'),
registroId: v.id('registrosPonto')
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -1143,13 +1173,13 @@ export const obterRegistro = query({
simbolo: simbolo
? {
nome: simbolo.nome,
tipo: simbolo.tipo,
tipo: simbolo.tipo
}
: null,
: null
}
: null,
: null
};
},
}
});
/**
@@ -1163,7 +1193,9 @@ function calcularCargaHorariaDiaria(config: {
}): number {
const [horaEntrada, minutoEntrada] = config.horarioEntrada.split(':').map(Number);
const [horaSaidaAlmoco, minutoSaidaAlmoco] = config.horarioSaidaAlmoco.split(':').map(Number);
const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco.split(':').map(Number);
const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco
.split(':')
.map(Number);
const [horaSaida, minutoSaida] = config.horarioSaida.split(':').map(Number);
const minutosEntrada = horaEntrada * 60 + minutoEntrada;
@@ -1181,11 +1213,13 @@ function calcularCargaHorariaDiaria(config: {
/**
* Calcula horas trabalhadas do dia baseado nos registros
*/
function calcularHorasTrabalhadas(registros: Array<{
tipo: string;
hora: number;
minuto: number;
}>): number {
function calcularHorasTrabalhadas(
registros: Array<{
tipo: string;
hora: number;
minuto: number;
}>
): number {
// Ordenar registros por timestamp
const registrosOrdenados = [...registros].sort((a, b) => {
const minutosA = a.hora * 60 + a.minuto;
@@ -1268,7 +1302,7 @@ async function atualizarBancoHoras(
horasTrabalhadas,
saldoMinutos,
registrosPontoIds,
calculadoEm: Date.now(),
calculadoEm: Date.now()
});
} else {
// Criar novo
@@ -1279,7 +1313,7 @@ async function atualizarBancoHoras(
horasTrabalhadas,
saldoMinutos,
registrosPontoIds,
calculadoEm: Date.now(),
calculadoEm: Date.now()
});
}
}
@@ -1291,7 +1325,7 @@ export const obterHistoricoESaldoDia = query({
args: {
funcionarioId: v.id('funcionarios'),
data: v.string(), // YYYY-MM-DD
_refresh: v.optional(v.number()), // Parâmetro usado pelo frontend para forçar refresh
_refresh: v.optional(v.number()) // Parâmetro usado pelo frontend para forçar refresh
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -1302,7 +1336,7 @@ export const obterHistoricoESaldoDia = query({
registros: [],
cargaHorariaDiaria: 0,
horasTrabalhadas: 0,
saldoMinutos: 0,
saldoMinutos: 0
};
}
@@ -1319,7 +1353,7 @@ export const obterHistoricoESaldoDia = query({
)
.order('asc')
.collect();
console.log('[obterHistoricoESaldoDia] Registros encontrados:', registros.length, {
funcionarioId: args.funcionarioId,
data: args.data
@@ -1336,7 +1370,7 @@ export const obterHistoricoESaldoDia = query({
registros: [],
cargaHorariaDiaria: 0,
horasTrabalhadas: 0,
saldoMinutos: 0,
saldoMinutos: 0
};
}
@@ -1357,10 +1391,10 @@ export const obterHistoricoESaldoDia = query({
saldoFormatado: {
horas,
minutos,
positivo,
},
positivo
}
};
},
}
});
/**
@@ -1368,7 +1402,7 @@ export const obterHistoricoESaldoDia = query({
*/
export const obterBancoHorasFuncionario = query({
args: {
funcionarioId: v.id('funcionarios'),
funcionarioId: v.id('funcionarios')
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -1394,9 +1428,9 @@ export const obterBancoHorasFuncionario = query({
return {
bancosHoras,
saldoAcumuladoMinutos,
totalDias: bancosHoras.length,
totalDias: bancosHoras.length
};
},
}
});
/**
@@ -1432,7 +1466,7 @@ export const editarRegistroPonto = mutation({
motivoId: v.optional(v.string()),
motivoTipo: v.optional(v.string()),
motivoDescricao: v.optional(v.string()),
observacoes: v.optional(v.string()),
observacoes: v.optional(v.string())
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -1460,7 +1494,7 @@ export const editarRegistroPonto = mutation({
await ctx.db.patch(args.registroId, {
hora: args.horaNova,
minuto: args.minutoNova,
editadoPorGestor: true,
editadoPorGestor: true
});
// Criar registro de homologação
@@ -1476,12 +1510,12 @@ export const editarRegistroPonto = mutation({
motivoTipo: args.motivoTipo,
motivoDescricao: args.motivoDescricao,
observacoes: args.observacoes,
criadoEm: Date.now(),
criadoEm: Date.now()
});
// Atualizar registro com ID da homologação
await ctx.db.patch(args.registroId, {
homologacaoId,
homologacaoId
});
// Recalcular banco de horas do dia
@@ -1495,7 +1529,7 @@ export const editarRegistroPonto = mutation({
}
return { success: true, homologacaoId };
},
}
});
/**
@@ -1511,7 +1545,7 @@ export const ajustarBancoHoras = mutation({
motivoId: v.optional(v.string()),
motivoTipo: v.optional(v.string()),
motivoDescricao: v.optional(v.string()),
observacoes: v.optional(v.string()),
observacoes: v.optional(v.string())
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -1526,8 +1560,7 @@ export const ajustarBancoHoras = mutation({
}
// Calcular ajuste em minutos
const ajusteMinutos =
args.periodoDias * 24 * 60 + args.periodoHoras * 60 + args.periodoMinutos;
const ajusteMinutos = args.periodoDias * 24 * 60 + args.periodoHoras * 60 + args.periodoMinutos;
// Aplicar sinal baseado no tipo de ajuste
let ajusteFinal = ajusteMinutos;
@@ -1547,7 +1580,7 @@ export const ajustarBancoHoras = mutation({
if (bancoHorasAtual) {
// Atualizar saldo do dia atual
await ctx.db.patch(bancoHorasAtual._id, {
saldoMinutos: bancoHorasAtual.saldoMinutos + ajusteFinal,
saldoMinutos: bancoHorasAtual.saldoMinutos + ajusteFinal
});
} else {
// Criar novo registro de banco de horas para o ajuste
@@ -1569,7 +1602,7 @@ export const ajustarBancoHoras = mutation({
horasTrabalhadas: 0,
saldoMinutos: ajusteFinal,
registrosPontoIds: [],
calculadoEm: Date.now(),
calculadoEm: Date.now()
});
}
@@ -1586,11 +1619,11 @@ export const ajustarBancoHoras = mutation({
periodoHoras: args.periodoHoras,
periodoMinutos: args.periodoMinutos,
ajusteMinutos: ajusteFinal,
criadoEm: Date.now(),
criadoEm: Date.now()
});
return { success: true, homologacaoId, ajusteMinutos: ajusteFinal };
},
}
});
/**
@@ -1598,7 +1631,7 @@ export const ajustarBancoHoras = mutation({
*/
export const listarHomologacoes = query({
args: {
funcionarioId: v.optional(v.id('funcionarios')),
funcionarioId: v.optional(v.id('funcionarios'))
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -1656,27 +1689,27 @@ export const listarHomologacoes = query({
funcionario: funcionario
? {
nome: funcionario.nome,
matricula: funcionario.matricula,
matricula: funcionario.matricula
}
: null,
fotoPerfilUrl,
gestor: gestor
? {
nome: gestor.nome,
nome: gestor.nome
}
: null,
registro: registro
? {
data: registro.data,
tipo: registro.tipo,
tipo: registro.tipo
}
: null,
: null
};
})
);
return homologacoesComDetalhes;
},
}
});
/**
@@ -1684,7 +1717,7 @@ export const listarHomologacoes = query({
*/
export const excluirHomologacao = mutation({
args: {
homologacaoId: v.id('homologacoesPonto'),
homologacaoId: v.id('homologacoesPonto')
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -1698,7 +1731,11 @@ export const excluirHomologacao = mutation({
}
// Verificar se é gestor do funcionário
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, homologacao.funcionarioId);
const isGestor = await verificarGestorDoFuncionario(
ctx,
usuario._id,
homologacao.funcionarioId
);
if (!isGestor && homologacao.gestorId !== usuario._id) {
throw new Error('Você não tem permissão para excluir esta homologação');
}
@@ -1709,7 +1746,7 @@ export const excluirHomologacao = mutation({
if (registro && registro.homologacaoId === args.homologacaoId) {
await ctx.db.patch(homologacao.registroId, {
homologacaoId: undefined,
editadoPorGestor: false,
editadoPorGestor: false
});
}
}
@@ -1718,7 +1755,7 @@ export const excluirHomologacao = mutation({
await ctx.db.delete(args.homologacaoId);
return { success: true };
},
}
});
/**
@@ -1744,10 +1781,10 @@ export const obterMotivosAtestados = query({
'Ajuste Administrativo',
'Compensação de Horas',
'Abono',
'Desconto em Folha',
],
'Desconto em Folha'
]
};
},
}
});
/**
@@ -1763,7 +1800,7 @@ export const criarDispensaRegistro = mutation({
horaFim: v.number(),
minutoFim: v.number(),
motivo: v.string(),
isento: v.boolean(),
isento: v.boolean()
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -1798,11 +1835,11 @@ export const criarDispensaRegistro = mutation({
motivo: args.motivo,
isento: args.isento,
ativo: true,
criadoEm: Date.now(),
criadoEm: Date.now()
});
return { success: true, dispensaId };
},
}
});
/**
@@ -1810,7 +1847,7 @@ export const criarDispensaRegistro = mutation({
*/
export const removerDispensaRegistro = mutation({
args: {
dispensaId: v.id('dispensasRegistro'),
dispensaId: v.id('dispensasRegistro')
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -1831,11 +1868,11 @@ export const removerDispensaRegistro = mutation({
// Desativar dispensa
await ctx.db.patch(args.dispensaId, {
ativo: false,
ativo: false
});
return { success: true };
},
}
});
/**
@@ -1844,7 +1881,7 @@ export const removerDispensaRegistro = mutation({
export const listarDispensas = query({
args: {
funcionarioId: v.optional(v.id('funcionarios')),
apenasAtivas: v.optional(v.boolean()),
apenasAtivas: v.optional(v.boolean())
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -1923,22 +1960,22 @@ export const listarDispensas = query({
funcionario: funcionario
? {
nome: funcionario.nome,
matricula: funcionario.matricula,
matricula: funcionario.matricula
}
: null,
fotoPerfilUrl,
gestor: gestor
? {
nome: gestor.nome,
nome: gestor.nome
}
: null,
expirada,
expirada
};
})
);
return dispensasComDetalhes;
},
}
});
/**
@@ -1949,7 +1986,7 @@ export const verificarDispensaAtiva = query({
funcionarioId: v.id('funcionarios'),
data: v.string(), // YYYY-MM-DD
hora: v.optional(v.number()),
minuto: v.optional(v.number()),
minuto: v.optional(v.number())
},
handler: async (ctx, args) => {
const dispensas = await ctx.db
@@ -1966,7 +2003,7 @@ export const verificarDispensaAtiva = query({
return {
dispensado: true,
dispensa,
motivo: 'Isento de registro (caso excepcional)',
motivo: 'Isento de registro (caso excepcional)'
};
}
@@ -1992,7 +2029,7 @@ export const verificarDispensaAtiva = query({
return {
dispensado: true,
dispensa,
motivo: dispensa.motivo,
motivo: dispensa.motivo
};
}
} else {
@@ -2000,7 +2037,7 @@ export const verificarDispensaAtiva = query({
return {
dispensado: true,
dispensa,
motivo: dispensa.motivo,
motivo: dispensa.motivo
};
}
}
@@ -2009,8 +2046,7 @@ export const verificarDispensaAtiva = query({
return {
dispensado: false,
dispensa: null,
motivo: null,
motivo: null
};
},
}
});

View File

@@ -1,12 +1,12 @@
import { v } from "convex/values";
import { query } from "./_generated/server";
import { Id } from "./_generated/dataModel";
import type { QueryCtx } from "./_generated/server";
import { v } from 'convex/values';
import { query } from './_generated/server';
import { Id } from './_generated/dataModel';
import type { QueryCtx } from './_generated/server';
/**
* SISTEMA DE CÁLCULO DE SALDO DE FÉRIAS
* Suporte a múltiplos regimes de trabalho
*
*
* ============================================
* REGRAS CLT (Consolidação das Leis do Trabalho):
* ============================================
@@ -17,7 +17,7 @@ import type { QueryCtx } from "./_generated/server";
* - Um período deve ter no mínimo 14 dias
* - Demais períodos: mínimo 5 dias cada
* - Abono pecuniário: vender 1/3 das férias (10 dias) - OPCIONAL
*
*
* ============================================
* REGRAS SERVIDOR PÚBLICO ESTADUAL DE PERNAMBUCO
* Lei nº 6.123/1968 - Estatuto dos Funcionários Públicos Civis do Estado de PE
@@ -37,448 +37,444 @@ import type { QueryCtx } from "./_generated/server";
* - Seguem as mesmas diretrizes do regime estadual acima
*/
type RegimeTrabalho = "clt" | "estatutario_pe" | "estatutario_federal" | "estatutario_municipal";
type RegimeTrabalho = 'clt' | 'estatutario_pe' | 'estatutario_federal' | 'estatutario_municipal';
// Configurações por regime
const REGIMES_CONFIG = {
clt: {
nome: "CLT - Consolidação das Leis do Trabalho",
maxPeriodos: 3,
minDiasPeriodo: 5,
minDiasPeriodoPrincipal: 14,
abonoPermitido: true,
maxDiasAbono: 10,
},
estatutario_pe: {
nome: "Servidor Público Estadual de Pernambuco",
maxPeriodos: 2,
minDiasPeriodo: 15, // Mínimo 15 dias por período
minDiasPeriodoPrincipal: null, // Não há essa regra
abonoPermitido: false,
maxDiasAbono: 0,
periodosPermitidos: [15, 30], // Apenas 15 ou 30 dias por período
},
estatutario_federal: {
nome: "Servidor Público Federal",
maxPeriodos: 3,
minDiasPeriodo: 5,
minDiasPeriodoPrincipal: 14,
abonoPermitido: true,
maxDiasAbono: 10,
},
estatutario_municipal: {
nome: "Servidor Público Municipal",
maxPeriodos: 2,
minDiasPeriodo: 15, // Mínimo 15 dias por período
minDiasPeriodoPrincipal: null,
abonoPermitido: false,
maxDiasAbono: 0,
periodosPermitidos: [15, 30], // Apenas 15 ou 30 dias por período
},
clt: {
nome: 'CLT - Consolidação das Leis do Trabalho',
maxPeriodos: 3,
minDiasPeriodo: 5,
minDiasPeriodoPrincipal: 14,
abonoPermitido: true,
maxDiasAbono: 10
},
estatutario_pe: {
nome: 'Servidor Público Estadual de Pernambuco',
maxPeriodos: 2,
minDiasPeriodo: 15, // Mínimo 15 dias por período
minDiasPeriodoPrincipal: null, // Não há essa regra
abonoPermitido: false,
maxDiasAbono: 0,
periodosPermitidos: [15, 30] // Apenas 15 ou 30 dias por período
},
estatutario_federal: {
nome: 'Servidor Público Federal',
maxPeriodos: 3,
minDiasPeriodo: 5,
minDiasPeriodoPrincipal: 14,
abonoPermitido: true,
maxDiasAbono: 10
},
estatutario_municipal: {
nome: 'Servidor Público Municipal',
maxPeriodos: 2,
minDiasPeriodo: 15, // Mínimo 15 dias por período
minDiasPeriodoPrincipal: null,
abonoPermitido: false,
maxDiasAbono: 0,
periodosPermitidos: [15, 30] // Apenas 15 ou 30 dias por período
}
};
// Helper: Calcular dias entre duas datas
function calcularDiasEntreDatas(dataInicio: string, dataFim: string): number {
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; // +1 para incluir ambos os dias
return diffDays;
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; // +1 para incluir ambos os dias
return diffDays;
}
// Helper: Calcular data de fim do período aquisitivo
function calcularDataFimPeriodo(dataAdmissao: string, anosPassados: number): string {
const dataInicio = new Date(dataAdmissao);
dataInicio.setFullYear(dataInicio.getFullYear() + anosPassados);
return dataInicio.toISOString().split('T')[0];
const dataInicio = new Date(dataAdmissao);
dataInicio.setFullYear(dataInicio.getFullYear() + anosPassados);
return dataInicio.toISOString().split('T')[0];
}
// Helper: Obter regime de trabalho do funcionário
async function obterRegimeTrabalho(ctx: QueryCtx, funcionarioId: Id<"funcionarios">): Promise<RegimeTrabalho> {
const funcionario = await ctx.db.get(funcionarioId);
return funcionario?.regimeTrabalho || "clt"; // Default CLT
async function obterRegimeTrabalho(
ctx: QueryCtx,
funcionarioId: Id<'funcionarios'>
): Promise<RegimeTrabalho> {
const funcionario = await ctx.db.get(funcionarioId);
return funcionario?.regimeTrabalho || 'clt'; // Default CLT
}
// Helper: Calcular saldo dinamicamente baseado na tabela ferias
async function calcularSaldo(
ctx: QueryCtx,
funcionarioId: Id<"funcionarios">,
anoReferencia: number,
feriasIdExcluir?: Id<"ferias"> // ID do período a excluir do cálculo (para ajustes)
ctx: QueryCtx,
funcionarioId: Id<'funcionarios'>,
anoReferencia: number,
feriasIdExcluir?: Id<'ferias'> // ID do período a excluir do cálculo (para ajustes)
): Promise<{
diasDireito: number;
diasUsados: number;
diasPendentes: number;
diasDisponiveis: number;
diasAbono: number;
dataInicio: string;
dataFim: string;
status: "ativo" | "vencido" | "concluido";
diasDireito: number;
diasUsados: number;
diasPendentes: number;
diasDisponiveis: number;
diasAbono: number;
dataInicio: string;
dataFim: string;
status: 'ativo' | 'vencido' | 'concluido';
} | null> {
const funcionario = await ctx.db.get(funcionarioId);
if (!funcionario || !funcionario.admissaoData) return null;
const funcionario = await ctx.db.get(funcionarioId);
if (!funcionario || !funcionario.admissaoData) return null;
const regime = funcionario.regimeTrabalho || "clt";
const config = REGIMES_CONFIG[regime];
const regime = funcionario.regimeTrabalho || 'clt';
const config = REGIMES_CONFIG[regime];
// Calcular anos desde admissão
const dataAdmissao = new Date(funcionario.admissaoData);
const anosDesdeAdmissao = anoReferencia - dataAdmissao.getFullYear();
// Calcular anos desde admissão
const dataAdmissao = new Date(funcionario.admissaoData);
const anosDesdeAdmissao = anoReferencia - dataAdmissao.getFullYear();
if (anosDesdeAdmissao < 1) return null; // Ainda não tem direito
if (anosDesdeAdmissao < 1) return null; // Ainda não tem direito
const dataInicio = calcularDataFimPeriodo(
funcionario.admissaoData,
anosDesdeAdmissao - 1
);
const dataFim = calcularDataFimPeriodo(
funcionario.admissaoData,
anosDesdeAdmissao
);
const dataInicio = calcularDataFimPeriodo(funcionario.admissaoData, anosDesdeAdmissao - 1);
const dataFim = calcularDataFimPeriodo(funcionario.admissaoData, anosDesdeAdmissao);
// Buscar todos os registros de férias para este funcionário e ano
const todasFerias = await ctx.db
.query("ferias")
.withIndex("by_funcionario_and_ano", (q) =>
q.eq("funcionarioId", funcionarioId).eq("anoReferencia", anoReferencia)
)
.collect();
// Buscar todos os registros de férias para este funcionário e ano
const todasFerias = await ctx.db
.query('ferias')
.withIndex('by_funcionario_and_ano', (q) =>
q.eq('funcionarioId', funcionarioId).eq('anoReferencia', anoReferencia)
)
.collect();
// Filtrar períodos a excluir (para ajustes)
const feriasFiltradas = feriasIdExcluir
? todasFerias.filter((f) => f._id !== feriasIdExcluir)
: todasFerias;
// Filtrar períodos a excluir (para ajustes)
const feriasFiltradas = feriasIdExcluir
? todasFerias.filter((f) => f._id !== feriasIdExcluir)
: todasFerias;
// Calcular dias usados (aprovado, data_ajustada_aprovada, EmFérias)
const diasUsados = feriasFiltradas
.filter(
(f) =>
f.status === "aprovado" ||
f.status === "data_ajustada_aprovada" ||
f.status === "EmFérias"
)
.reduce((acc, f) => acc + f.diasFerias, 0);
// Calcular dias usados (aprovado, data_ajustada_aprovada, EmFérias)
const diasUsados = feriasFiltradas
.filter(
(f) =>
f.status === 'aprovado' || f.status === 'data_ajustada_aprovada' || f.status === 'EmFérias'
)
.reduce((acc, f) => acc + f.diasFerias, 0);
// Calcular dias pendentes (aguardando_aprovacao)
const diasPendentes = feriasFiltradas
.filter((f) => f.status === "aguardando_aprovacao")
.reduce((acc, f) => acc + f.diasFerias, 0);
// Calcular dias pendentes (aguardando_aprovacao)
const diasPendentes = feriasFiltradas
.filter((f) => f.status === 'aguardando_aprovacao')
.reduce((acc, f) => acc + f.diasFerias, 0);
// Calcular dias de abono
const diasAbono = feriasFiltradas.reduce((acc, f) => acc + f.diasAbono, 0);
// Calcular dias de abono
const diasAbono = feriasFiltradas.reduce((acc, f) => acc + f.diasAbono, 0);
// Calcular dias disponíveis
const diasDireito = 30;
const diasDisponiveis = diasDireito - diasUsados - diasPendentes - diasAbono;
// Calcular dias disponíveis
const diasDireito = 30;
const diasDisponiveis = diasDireito - diasUsados - diasPendentes - diasAbono;
// Determinar status do período
const hoje = new Date();
const dataFimPeriodo = new Date(dataFim);
let status: "ativo" | "vencido" | "concluido";
if (diasDireito - diasUsados - diasAbono <= 0) {
status = "concluido";
} else if (hoje > dataFimPeriodo) {
status = "vencido";
} else {
status = "ativo";
}
// Determinar status do período
const hoje = new Date();
const dataFimPeriodo = new Date(dataFim);
let status: 'ativo' | 'vencido' | 'concluido';
return {
diasDireito,
diasUsados,
diasPendentes,
diasDisponiveis,
diasAbono,
dataInicio,
dataFim,
status,
};
if (diasDireito - diasUsados - diasAbono <= 0) {
status = 'concluido';
} else if (hoje > dataFimPeriodo) {
status = 'vencido';
} else {
status = 'ativo';
}
return {
diasDireito,
diasUsados,
diasPendentes,
diasDisponiveis,
diasAbono,
dataInicio,
dataFim,
status
};
}
/**
* Query: Obter saldo de férias de um funcionário para um ano específico
*/
export const obterSaldo = query({
args: {
funcionarioId: v.id("funcionarios"),
anoReferencia: v.number(),
},
returns: v.union(
v.object({
anoReferencia: v.number(),
diasDireito: v.number(),
diasUsados: v.number(),
diasPendentes: v.number(),
diasDisponiveis: v.number(),
diasAbono: v.number(),
abonoPermitido: v.boolean(),
status: v.union(v.literal("ativo"), v.literal("vencido"), v.literal("concluido")),
dataInicio: v.string(),
dataFim: v.string(),
regimeTrabalho: v.string(),
}),
v.null()
),
handler: async (ctx, args) => {
const saldo = await calcularSaldo(ctx, args.funcionarioId, args.anoReferencia);
if (!saldo) return null;
args: {
funcionarioId: v.id('funcionarios'),
anoReferencia: v.number()
},
returns: v.union(
v.object({
anoReferencia: v.number(),
diasDireito: v.number(),
diasUsados: v.number(),
diasPendentes: v.number(),
diasDisponiveis: v.number(),
diasAbono: v.number(),
abonoPermitido: v.boolean(),
status: v.union(v.literal('ativo'), v.literal('vencido'), v.literal('concluido')),
dataInicio: v.string(),
dataFim: v.string(),
regimeTrabalho: v.string()
}),
v.null()
),
handler: async (ctx, args) => {
const saldo = await calcularSaldo(ctx, args.funcionarioId, args.anoReferencia);
if (!saldo) return null;
const funcionario = await ctx.db.get(args.funcionarioId);
const regime = funcionario?.regimeTrabalho || "clt";
const config = REGIMES_CONFIG[regime];
const funcionario = await ctx.db.get(args.funcionarioId);
const regime = funcionario?.regimeTrabalho || 'clt';
const config = REGIMES_CONFIG[regime];
return {
anoReferencia: args.anoReferencia,
...saldo,
abonoPermitido: config.abonoPermitido,
regimeTrabalho: config.nome,
};
},
return {
anoReferencia: args.anoReferencia,
...saldo,
abonoPermitido: config.abonoPermitido,
regimeTrabalho: config.nome
};
}
});
/**
* Query: Listar todos os saldos de um funcionário
*/
export const listarSaldos = query({
args: {
funcionarioId: v.id("funcionarios"),
},
returns: v.array(
v.object({
anoReferencia: v.number(),
diasDireito: v.number(),
diasUsados: v.number(),
diasPendentes: v.number(),
diasDisponiveis: v.number(),
diasAbono: v.number(),
abonoPermitido: v.boolean(),
status: v.union(v.literal("ativo"), v.literal("vencido"), v.literal("concluido")),
dataInicio: v.string(),
dataFim: v.string(),
})
),
handler: async (ctx, args) => {
const funcionario = await ctx.db.get(args.funcionarioId);
if (!funcionario || !funcionario.admissaoData) return [];
args: {
funcionarioId: v.id('funcionarios')
},
returns: v.array(
v.object({
anoReferencia: v.number(),
diasDireito: v.number(),
diasUsados: v.number(),
diasPendentes: v.number(),
diasDisponiveis: v.number(),
diasAbono: v.number(),
abonoPermitido: v.boolean(),
status: v.union(v.literal('ativo'), v.literal('vencido'), v.literal('concluido')),
dataInicio: v.string(),
dataFim: v.string()
})
),
handler: async (ctx, args) => {
const funcionario = await ctx.db.get(args.funcionarioId);
if (!funcionario || !funcionario.admissaoData) return [];
const regime = funcionario.regimeTrabalho || "clt";
const config = REGIMES_CONFIG[regime];
const regime = funcionario.regimeTrabalho || 'clt';
const config = REGIMES_CONFIG[regime];
const dataAdmissao = new Date(funcionario.admissaoData);
const anoAtual = new Date().getFullYear();
const anosDesdeAdmissao = anoAtual - dataAdmissao.getFullYear();
const dataAdmissao = new Date(funcionario.admissaoData);
const anoAtual = new Date().getFullYear();
const anosDesdeAdmissao = anoAtual - dataAdmissao.getFullYear();
const saldos = [];
const saldos = [];
// Calcular saldos para os últimos 3 anos (atual, anterior e anterior ao anterior)
for (let i = 0; i < 3; i++) {
const ano = anoAtual - i;
const anosPeriodo = ano - dataAdmissao.getFullYear();
// Calcular saldos para os últimos 3 anos (atual, anterior e anterior ao anterior)
for (let i = 0; i < 3; i++) {
const ano = anoAtual - i;
const anosPeriodo = ano - dataAdmissao.getFullYear();
if (anosPeriodo < 1) continue;
if (anosPeriodo < 1) continue;
const saldo = await calcularSaldo(ctx, args.funcionarioId, ano);
if (saldo) {
saldos.push({
anoReferencia: ano,
...saldo,
abonoPermitido: config.abonoPermitido,
});
}
}
const saldo = await calcularSaldo(ctx, args.funcionarioId, ano);
if (saldo) {
saldos.push({
anoReferencia: ano,
...saldo,
abonoPermitido: config.abonoPermitido
});
}
}
return saldos;
},
return saldos;
}
});
/**
* Query: Validar solicitação de férias (regras CLT ou Servidor Público PE)
*/
export const validarSolicitacao = query({
args: {
funcionarioId: v.id("funcionarios"),
anoReferencia: v.number(),
periodos: v.array(
v.object({
dataInicio: v.string(),
dataFim: v.string(),
})
),
feriasIdExcluir: v.optional(v.id("ferias")), // ID do período a excluir do cálculo de saldo (para ajustes)
},
returns: v.object({
valido: v.boolean(),
erros: v.array(v.string()),
avisos: v.array(v.string()),
totalDias: v.number(),
regimeTrabalho: v.string(),
}),
handler: async (ctx, args) => {
const erros: string[] = [];
const avisos: string[] = [];
let totalDias = 0;
args: {
funcionarioId: v.id('funcionarios'),
anoReferencia: v.number(),
periodos: v.array(
v.object({
dataInicio: v.string(),
dataFim: v.string()
})
),
feriasIdExcluir: v.optional(v.id('ferias')) // ID do período a excluir do cálculo de saldo (para ajustes)
},
returns: v.object({
valido: v.boolean(),
erros: v.array(v.string()),
avisos: v.array(v.string()),
totalDias: v.number(),
regimeTrabalho: v.string()
}),
handler: async (ctx, args) => {
const erros: string[] = [];
const avisos: string[] = [];
let totalDias = 0;
// Obter regime de trabalho
const regime = await obterRegimeTrabalho(ctx, args.funcionarioId);
const config = REGIMES_CONFIG[regime];
// Obter regime de trabalho
const regime = await obterRegimeTrabalho(ctx, args.funcionarioId);
const config = REGIMES_CONFIG[regime];
// Validação 1: Número de períodos
if (args.periodos.length === 0) {
erros.push("É necessário adicionar pelo menos 1 período de férias");
}
// Validação 1: Número de períodos
if (args.periodos.length === 0) {
erros.push('É necessário adicionar pelo menos 1 período de férias');
}
if (args.periodos.length > config.maxPeriodos) {
erros.push(
`Máximo de ${config.maxPeriodos} períodos permitidos para ${config.nome}`
);
}
if (args.periodos.length > config.maxPeriodos) {
erros.push(`Máximo de ${config.maxPeriodos} períodos permitidos para ${config.nome}`);
}
// Calcular dias de cada período e validar
const diasPorPeriodo: number[] = [];
for (const periodo of args.periodos) {
const dias = calcularDiasEntreDatas(periodo.dataInicio, periodo.dataFim);
diasPorPeriodo.push(dias);
totalDias += dias;
// Calcular dias de cada período e validar
const diasPorPeriodo: number[] = [];
for (const periodo of args.periodos) {
const dias = calcularDiasEntreDatas(periodo.dataInicio, periodo.dataFim);
diasPorPeriodo.push(dias);
totalDias += dias;
// Validação 2: Mínimo de dias por período
if (dias < config.minDiasPeriodo) {
erros.push(
`Período de ${dias} dias é inválido. Mínimo: ${config.minDiasPeriodo} dias corridos (${config.nome})`
);
}
// Validação 2: Mínimo de dias por período
if (dias < config.minDiasPeriodo) {
erros.push(
`Período de ${dias} dias é inválido. Mínimo: ${config.minDiasPeriodo} dias corridos (${config.nome})`
);
}
// Validação específica para regime estatutário PE e Municipal
if ((regime === "estatutario_pe" || regime === "estatutario_municipal") && 'periodosPermitidos' in config) {
if (!config.periodosPermitidos.includes(dias)) {
erros.push(
`Para ${config.nome}, os períodos devem ter exatamente 15 ou 30 dias. Período de ${dias} dias não é permitido.`
);
}
}
}
// Validação específica para regime estatutário PE e Municipal
if (
(regime === 'estatutario_pe' || regime === 'estatutario_municipal') &&
'periodosPermitidos' in config
) {
if (!config.periodosPermitidos.includes(dias)) {
erros.push(
`Para ${config.nome}, os períodos devem ter exatamente 15 ou 30 dias. Período de ${dias} dias não é permitido.`
);
}
}
}
// Validação específica para regime estatutário PE e Municipal
// Permite períodos fracionados: cada período deve ser 15 ou 30 dias
// Total não pode exceder 30 dias, mas pode ser menos (períodos fracionados)
if ((regime === "estatutario_pe" || regime === "estatutario_municipal")) {
// Verificar se cada período individual é válido (15 ou 30 dias)
for (const dias of diasPorPeriodo) {
if (dias !== 15 && dias !== 30) {
erros.push(
`Para ${config.nome}, cada período deve ter exatamente 15 ou 30 dias. Período de ${dias} dias não é permitido.`
);
}
}
// Validação específica para regime estatutário PE e Municipal
// Permite períodos fracionados: cada período deve ser 15 ou 30 dias
// Total não pode exceder 30 dias, mas pode ser menos (períodos fracionados)
if (regime === 'estatutario_pe' || regime === 'estatutario_municipal') {
// Verificar se cada período individual é válido (15 ou 30 dias)
for (const dias of diasPorPeriodo) {
if (dias !== 15 && dias !== 30) {
erros.push(
`Para ${config.nome}, cada período deve ter exatamente 15 ou 30 dias. Período de ${dias} dias não é permitido.`
);
}
}
// Total não pode exceder 30 dias
if (totalDias > 30) {
erros.push(
`Para ${config.nome}, o total de dias não pode exceder 30 dias. Total solicitado: ${totalDias} dias.`
);
}
// Total não pode exceder 30 dias
if (totalDias > 30) {
erros.push(
`Para ${config.nome}, o total de dias não pode exceder 30 dias. Total solicitado: ${totalDias} dias.`
);
}
// Máximo de 2 períodos
if (args.periodos.length > 2) {
erros.push(
`Para ${config.nome}, o máximo de períodos permitidos é 2.`
);
}
}
// Máximo de 2 períodos
if (args.periodos.length > 2) {
erros.push(`Para ${config.nome}, o máximo de períodos permitidos é 2.`);
}
}
// Validação 3: CLT requer um período com 14+ dias se dividir
if (regime === "clt" && args.periodos.length > 1 && config.minDiasPeriodoPrincipal) {
const temPeriodo14Dias = diasPorPeriodo.some((d) => d >= config.minDiasPeriodoPrincipal!);
if (!temPeriodo14Dias) {
erros.push(
`Ao dividir férias em CLT, um período deve ter no mínimo ${config.minDiasPeriodoPrincipal} dias corridos`
);
}
}
// Validação 3: CLT requer um período com 14+ dias se dividir
if (regime === 'clt' && args.periodos.length > 1 && config.minDiasPeriodoPrincipal) {
const temPeriodo14Dias = diasPorPeriodo.some((d) => d >= config.minDiasPeriodoPrincipal!);
if (!temPeriodo14Dias) {
erros.push(
`Ao dividir férias em CLT, um período deve ter no mínimo ${config.minDiasPeriodoPrincipal} dias corridos`
);
}
}
// Validação 4: Verificar saldo disponível (calculado dinamicamente)
// Se for um ajuste (feriasIdExcluir fornecido), excluir esse período do cálculo
const saldo = await calcularSaldo(ctx, args.funcionarioId, args.anoReferencia, args.feriasIdExcluir);
// Validação 4: Verificar saldo disponível (calculado dinamicamente)
// Se for um ajuste (feriasIdExcluir fornecido), excluir esse período do cálculo
const saldo = await calcularSaldo(
ctx,
args.funcionarioId,
args.anoReferencia,
args.feriasIdExcluir
);
if (!saldo) {
erros.push(`Você ainda não tem direito a férias referentes ao ano ${args.anoReferencia}`);
} else {
// Verificar saldo disponível (já excluindo o período original se for ajuste)
if (totalDias > saldo.diasDisponiveis) {
erros.push(
`Total solicitado (${totalDias} dias) excede saldo disponível (${saldo.diasDisponiveis} dias)`
);
}
if (!saldo) {
erros.push(`Você ainda não tem direito a férias referentes ao ano ${args.anoReferencia}`);
} else {
// Verificar saldo disponível (já excluindo o período original se for ajuste)
if (totalDias > saldo.diasDisponiveis) {
erros.push(
`Total solicitado (${totalDias} dias) excede saldo disponível (${saldo.diasDisponiveis} dias)`
);
}
// Aviso: Saldo baixo
if (saldo.diasDisponiveis < 15 && saldo.diasDisponiveis > totalDias) {
avisos.push(
`Após essa solicitação, restará ${saldo.diasDisponiveis - totalDias} dias de ${args.anoReferencia}`
);
}
// Aviso: Saldo baixo
if (saldo.diasDisponiveis < 15 && saldo.diasDisponiveis > totalDias) {
avisos.push(
`Após essa solicitação, restará ${saldo.diasDisponiveis - totalDias} dias de ${args.anoReferencia}`
);
}
// Aviso: Férias vencendo
const hoje = new Date();
const dataFim = new Date(saldo.dataFim);
const diasAteVencer = Math.ceil((dataFim.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24));
if (diasAteVencer < 90 && diasAteVencer > 0) {
avisos.push(
`⚠️ Atenção: Seu período aquisitivo ${args.anoReferencia} vence em ${diasAteVencer} dias!`
);
}
// Aviso: Férias vencendo
const hoje = new Date();
const dataFim = new Date(saldo.dataFim);
const diasAteVencer = Math.ceil((dataFim.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24));
if (diasAteVencer < 90 && diasAteVencer > 0) {
avisos.push(
`⚠️ Atenção: Seu período aquisitivo ${args.anoReferencia} vence em ${diasAteVencer} dias!`
);
}
if (diasAteVencer < 0) {
avisos.push(
`⚠️ URGENTE: Seu período aquisitivo ${args.anoReferencia} está VENCIDO há ${Math.abs(diasAteVencer)} dias!`
);
}
}
if (diasAteVencer < 0) {
avisos.push(
`⚠️ URGENTE: Seu período aquisitivo ${args.anoReferencia} está VENCIDO há ${Math.abs(diasAteVencer)} dias!`
);
}
}
// Validação 5: Verificar conflitos de datas (sobreposição)
for (let i = 0; i < args.periodos.length; i++) {
for (let j = i + 1; j < args.periodos.length; j++) {
const inicio1 = new Date(args.periodos[i].dataInicio);
const fim1 = new Date(args.periodos[i].dataFim);
const inicio2 = new Date(args.periodos[j].dataInicio);
const fim2 = new Date(args.periodos[j].dataFim);
// Validação 5: Verificar conflitos de datas (sobreposição)
for (let i = 0; i < args.periodos.length; i++) {
for (let j = i + 1; j < args.periodos.length; j++) {
const inicio1 = new Date(args.periodos[i].dataInicio);
const fim1 = new Date(args.periodos[i].dataFim);
const inicio2 = new Date(args.periodos[j].dataInicio);
const fim2 = new Date(args.periodos[j].dataFim);
if (
(inicio1 <= fim2 && fim1 >= inicio2) ||
(inicio2 <= fim1 && fim2 >= inicio1)
) {
erros.push("Os períodos não podem se sobrepor");
}
}
}
if ((inicio1 <= fim2 && fim1 >= inicio2) || (inicio2 <= fim1 && fim2 >= inicio1)) {
erros.push('Os períodos não podem se sobrepor');
}
}
}
// Validação 6: Datas no futuro (aviso)
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
for (const periodo of args.periodos) {
const inicio = new Date(periodo.dataInicio);
if (inicio < hoje) {
avisos.push("⚠️ Período(s) com data de início no passado ou hoje");
break;
}
}
// Validação 6: Datas no futuro (aviso)
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
for (const periodo of args.periodos) {
const inicio = new Date(periodo.dataInicio);
if (inicio < hoje) {
avisos.push('⚠️ Período(s) com data de início no passado ou hoje');
break;
}
}
// Validação 7: Servidor PE - aviso sobre período preferencial para docentes
if (regime === "estatutario_pe" || regime === "estatutario_municipal") {
for (const periodo of args.periodos) {
const mes = new Date(periodo.dataInicio).getMonth() + 1;
if (mes === 12 || mes === 1) {
avisos.push("📅 Período preferencial para docentes (20/12 a 10/01)");
break;
}
}
}
// Validação 7: Servidor PE - aviso sobre período preferencial para docentes
if (regime === 'estatutario_pe' || regime === 'estatutario_municipal') {
for (const periodo of args.periodos) {
const mes = new Date(periodo.dataInicio).getMonth() + 1;
if (mes === 12 || mes === 1) {
avisos.push('📅 Período preferencial para docentes (20/12 a 10/01)');
break;
}
}
}
return {
valido: erros.length === 0,
erros,
avisos,
totalDias,
regimeTrabalho: config.nome,
};
},
return {
valido: erros.length === 0,
erros,
avisos,
totalDias,
regimeTrabalho: config.nome
};
}
});

View File

@@ -303,9 +303,7 @@ export const remove = mutation({
}
// Verificar se há passos de fluxo vinculados
const passosVinculados = await ctx.db
.query('flowSteps')
.collect();
const passosVinculados = await ctx.db.query('flowSteps').collect();
const temPassosVinculados = passosVinculados.some((p) => p.setorId === args.id);
if (temPassosVinculados) {
throw new Error('Não é possível excluir um setor vinculado a passos de fluxo');
@@ -315,4 +313,3 @@ export const remove = mutation({
return null;
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,365 +1,364 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { getCurrentUserFunction } from "./auth";
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
import { getCurrentUserFunction } from './auth';
// Query: Listar todos os times
// Tipo inferido automaticamente pelo Convex
export const listar = query({
args: {},
handler: async (ctx) => {
const times = await ctx.db.query("times").collect();
// Buscar gestor e contar membros de cada time
const timesComDetalhes = await Promise.all(
times.map(async (time) => {
const gestor = await ctx.db.get(time.gestorId);
const membrosAtivos = await ctx.db
.query("timesMembros")
.withIndex("by_time_and_ativo", (q) => q.eq("timeId", time._id).eq("ativo", true))
.collect();
return {
...time,
gestor,
totalMembros: membrosAtivos.length,
};
})
);
return timesComDetalhes;
},
args: {},
handler: async (ctx) => {
const times = await ctx.db.query('times').collect();
// Buscar gestor e contar membros de cada time
const timesComDetalhes = await Promise.all(
times.map(async (time) => {
const gestor = await ctx.db.get(time.gestorId);
const membrosAtivos = await ctx.db
.query('timesMembros')
.withIndex('by_time_and_ativo', (q) => q.eq('timeId', time._id).eq('ativo', true))
.collect();
return {
...time,
gestor,
totalMembros: membrosAtivos.length
};
})
);
return timesComDetalhes;
}
});
// Query: Obter time por ID com membros
// Tipo inferido automaticamente pelo Convex
export const obterPorId = query({
args: { id: v.id("times") },
handler: async (ctx, args) => {
const time = await ctx.db.get(args.id);
if (!time) return null;
const gestor = await ctx.db.get(time.gestorId);
const membrosRelacoes = await ctx.db
.query("timesMembros")
.withIndex("by_time_and_ativo", (q) => q.eq("timeId", args.id).eq("ativo", true))
.collect();
// Buscar dados completos dos membros
const membros = await Promise.all(
membrosRelacoes.map(async (rel) => {
const funcionario = await ctx.db.get(rel.funcionarioId);
return {
...rel,
funcionario,
};
})
);
return {
...time,
gestor,
membros,
};
},
args: { id: v.id('times') },
handler: async (ctx, args) => {
const time = await ctx.db.get(args.id);
if (!time) return null;
const gestor = await ctx.db.get(time.gestorId);
const membrosRelacoes = await ctx.db
.query('timesMembros')
.withIndex('by_time_and_ativo', (q) => q.eq('timeId', args.id).eq('ativo', true))
.collect();
// Buscar dados completos dos membros
const membros = await Promise.all(
membrosRelacoes.map(async (rel) => {
const funcionario = await ctx.db.get(rel.funcionarioId);
return {
...rel,
funcionario
};
})
);
return {
...time,
gestor,
membros
};
}
});
// Query: Obter time do funcionário
// Tipo inferido automaticamente pelo Convex
export const obterTimeFuncionario = query({
args: { funcionarioId: v.id("funcionarios") },
handler: async (ctx, args) => {
const relacao = await ctx.db
.query("timesMembros")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", args.funcionarioId))
.filter((q) => q.eq(q.field("ativo"), true))
.first();
if (!relacao) return null;
const time = await ctx.db.get(relacao.timeId);
if (!time) return null;
const gestor = await ctx.db.get(time.gestorId);
return {
...time,
gestor,
};
},
args: { funcionarioId: v.id('funcionarios') },
handler: async (ctx, args) => {
const relacao = await ctx.db
.query('timesMembros')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
.filter((q) => q.eq(q.field('ativo'), true))
.first();
if (!relacao) return null;
const time = await ctx.db.get(relacao.timeId);
if (!time) return null;
const gestor = await ctx.db.get(time.gestorId);
return {
...time,
gestor
};
}
});
// Query: Obter times do gestor
// Tipo inferido automaticamente pelo Convex
export const listarPorGestor = query({
args: { gestorId: v.id("usuarios") },
handler: async (ctx, args) => {
const times = await ctx.db
.query("times")
.withIndex("by_gestor", (q) => q.eq("gestorId", args.gestorId))
.filter((q) => q.eq(q.field("ativo"), true))
.collect();
const timesComMembros = await Promise.all(
times.map(async (time) => {
const membrosRelacoes = await ctx.db
.query("timesMembros")
.withIndex("by_time_and_ativo", (q) => q.eq("timeId", time._id).eq("ativo", true))
.collect();
const membros = await Promise.all(
membrosRelacoes.map(async (rel) => {
const funcionario = await ctx.db.get(rel.funcionarioId);
return {
...rel,
funcionario,
};
})
);
return {
...time,
membros,
};
})
);
return timesComMembros;
},
args: { gestorId: v.id('usuarios') },
handler: async (ctx, args) => {
const times = await ctx.db
.query('times')
.withIndex('by_gestor', (q) => q.eq('gestorId', args.gestorId))
.filter((q) => q.eq(q.field('ativo'), true))
.collect();
const timesComMembros = await Promise.all(
times.map(async (time) => {
const membrosRelacoes = await ctx.db
.query('timesMembros')
.withIndex('by_time_and_ativo', (q) => q.eq('timeId', time._id).eq('ativo', true))
.collect();
const membros = await Promise.all(
membrosRelacoes.map(async (rel) => {
const funcionario = await ctx.db.get(rel.funcionarioId);
return {
...rel,
funcionario
};
})
);
return {
...time,
membros
};
})
);
return timesComMembros;
}
});
export const listarSubordinadosDoGestorAtual = query({
args: {},
handler: async (ctx) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
return [];
}
args: {},
handler: async (ctx) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
return [];
}
const timesGestor = await ctx.db
.query("times")
.withIndex("by_gestor", (q) => q.eq("gestorId", usuario._id))
.collect();
const timesGestor = await ctx.db
.query('times')
.withIndex('by_gestor', (q) => q.eq('gestorId', usuario._id))
.collect();
const timesComoSuperior = await ctx.db
.query("times")
.withIndex("by_gestor_superior", (q) => q.eq("gestorSuperiorId", usuario._id))
.collect();
const timesComoSuperior = await ctx.db
.query('times')
.withIndex('by_gestor_superior', (q) => q.eq('gestorSuperiorId', usuario._id))
.collect();
const timesMap = new Map(
[...timesGestor, ...timesComoSuperior]
.filter((time) => time.ativo)
.map((time) => [time._id, time])
);
const timesMap = new Map(
[...timesGestor, ...timesComoSuperior]
.filter((time) => time.ativo)
.map((time) => [time._id, time])
);
const resultado = [];
const resultado = [];
for (const time of timesMap.values()) {
const membrosRelacoes = await ctx.db
.query("timesMembros")
.withIndex("by_time_and_ativo", (q) => q.eq("timeId", time._id).eq("ativo", true))
.collect();
for (const time of timesMap.values()) {
const membrosRelacoes = await ctx.db
.query('timesMembros')
.withIndex('by_time_and_ativo', (q) => q.eq('timeId', time._id).eq('ativo', true))
.collect();
const membros = [];
for (const rel of membrosRelacoes) {
const funcionario = await ctx.db.get(rel.funcionarioId);
if (funcionario) {
// Buscar foto do perfil do funcionário através do usuário associado
let fotoPerfilUrl: string | null = null;
const usuario = await ctx.db
.query("usuarios")
.withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", funcionario._id))
.first();
if (usuario?.fotoPerfil) {
fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil);
}
const membros = [];
for (const rel of membrosRelacoes) {
const funcionario = await ctx.db.get(rel.funcionarioId);
if (funcionario) {
// Buscar foto do perfil do funcionário através do usuário associado
let fotoPerfilUrl: string | null = null;
const usuario = await ctx.db
.query('usuarios')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id))
.first();
if (usuario?.fotoPerfil) {
fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil);
}
membros.push({
relacaoId: rel._id,
funcionario: {
...funcionario,
fotoPerfilUrl,
},
dataEntrada: rel.dataEntrada,
});
}
}
membros.push({
relacaoId: rel._id,
funcionario: {
...funcionario,
fotoPerfilUrl
},
dataEntrada: rel.dataEntrada
});
}
}
resultado.push({
...time,
membros,
});
}
resultado.push({
...time,
membros
});
}
return resultado;
},
return resultado;
}
});
// Mutation: Criar time
export const criar = mutation({
args: {
nome: v.string(),
descricao: v.optional(v.string()),
gestorId: v.id("usuarios"),
gestorSuperiorId: v.optional(v.id("usuarios")),
cor: v.optional(v.string()),
},
returns: v.id("times"),
handler: async (ctx, args) => {
const timeId = await ctx.db.insert("times", {
nome: args.nome,
descricao: args.descricao,
gestorId: args.gestorId,
gestorSuperiorId: args.gestorSuperiorId ?? args.gestorId,
ativo: true,
cor: args.cor || "#3B82F6",
});
return timeId;
},
args: {
nome: v.string(),
descricao: v.optional(v.string()),
gestorId: v.id('usuarios'),
gestorSuperiorId: v.optional(v.id('usuarios')),
cor: v.optional(v.string())
},
returns: v.id('times'),
handler: async (ctx, args) => {
const timeId = await ctx.db.insert('times', {
nome: args.nome,
descricao: args.descricao,
gestorId: args.gestorId,
gestorSuperiorId: args.gestorSuperiorId ?? args.gestorId,
ativo: true,
cor: args.cor || '#3B82F6'
});
return timeId;
}
});
// Mutation: Atualizar time
export const atualizar = mutation({
args: {
id: v.id("times"),
nome: v.string(),
descricao: v.optional(v.string()),
gestorId: v.id("usuarios"),
gestorSuperiorId: v.optional(v.id("usuarios")),
cor: v.optional(v.string()),
},
returns: v.null(),
handler: async (ctx, args) => {
const { id, ...dados } = args;
await ctx.db.patch(id, {
...dados,
gestorSuperiorId: dados.gestorSuperiorId ?? dados.gestorId,
});
return null;
},
args: {
id: v.id('times'),
nome: v.string(),
descricao: v.optional(v.string()),
gestorId: v.id('usuarios'),
gestorSuperiorId: v.optional(v.id('usuarios')),
cor: v.optional(v.string())
},
returns: v.null(),
handler: async (ctx, args) => {
const { id, ...dados } = args;
await ctx.db.patch(id, {
...dados,
gestorSuperiorId: dados.gestorSuperiorId ?? dados.gestorId
});
return null;
}
});
// Mutation: Desativar time
export const desativar = mutation({
args: { id: v.id("times") },
returns: v.null(),
handler: async (ctx, args) => {
// Desativar o time
await ctx.db.patch(args.id, { ativo: false });
// Desativar todos os membros
const membros = await ctx.db
.query("timesMembros")
.withIndex("by_time_and_ativo", (q) => q.eq("timeId", args.id).eq("ativo", true))
.collect();
for (const membro of membros) {
await ctx.db.patch(membro._id, {
ativo: false,
dataSaida: Date.now(),
});
args: { id: v.id('times') },
returns: v.null(),
handler: async (ctx, args) => {
// Desativar o time
await ctx.db.patch(args.id, { ativo: false });
await ctx.db.patch(membro.funcionarioId, { gestorId: undefined });
}
return null;
},
// Desativar todos os membros
const membros = await ctx.db
.query('timesMembros')
.withIndex('by_time_and_ativo', (q) => q.eq('timeId', args.id).eq('ativo', true))
.collect();
for (const membro of membros) {
await ctx.db.patch(membro._id, {
ativo: false,
dataSaida: Date.now()
});
await ctx.db.patch(membro.funcionarioId, { gestorId: undefined });
}
return null;
}
});
// Mutation: Adicionar membro ao time
export const adicionarMembro = mutation({
args: {
timeId: v.id("times"),
funcionarioId: v.id("funcionarios"),
},
returns: v.id("timesMembros"),
handler: async (ctx, args) => {
// Verificar se já não está em outro time ativo
const membroExistente = await ctx.db
.query("timesMembros")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", args.funcionarioId))
.filter((q) => q.eq(q.field("ativo"), true))
.first();
if (membroExistente) {
throw new Error("Funcionário já está em um time ativo");
}
const time = await ctx.db.get(args.timeId);
if (!time || !time.ativo) {
throw new Error("Time inválido ou inativo");
}
const membroId = await ctx.db.insert("timesMembros", {
timeId: args.timeId,
funcionarioId: args.funcionarioId,
dataEntrada: Date.now(),
ativo: true,
});
args: {
timeId: v.id('times'),
funcionarioId: v.id('funcionarios')
},
returns: v.id('timesMembros'),
handler: async (ctx, args) => {
// Verificar se já não está em outro time ativo
const membroExistente = await ctx.db
.query('timesMembros')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
.filter((q) => q.eq(q.field('ativo'), true))
.first();
await ctx.db.patch(args.funcionarioId, { gestorId: time.gestorId });
return membroId;
},
if (membroExistente) {
throw new Error('Funcionário já está em um time ativo');
}
const time = await ctx.db.get(args.timeId);
if (!time || !time.ativo) {
throw new Error('Time inválido ou inativo');
}
const membroId = await ctx.db.insert('timesMembros', {
timeId: args.timeId,
funcionarioId: args.funcionarioId,
dataEntrada: Date.now(),
ativo: true
});
await ctx.db.patch(args.funcionarioId, { gestorId: time.gestorId });
return membroId;
}
});
// Mutation: Remover membro do time
export const removerMembro = mutation({
args: { membroId: v.id("timesMembros") },
returns: v.null(),
handler: async (ctx, args) => {
const membro = await ctx.db.get(args.membroId);
if (!membro) {
throw new Error("Membro não encontrado");
}
await ctx.db.patch(args.membroId, {
ativo: false,
dataSaida: Date.now(),
});
args: { membroId: v.id('timesMembros') },
returns: v.null(),
handler: async (ctx, args) => {
const membro = await ctx.db.get(args.membroId);
if (!membro) {
throw new Error('Membro não encontrado');
}
await ctx.db.patch(args.membroId, {
ativo: false,
dataSaida: Date.now()
});
await ctx.db.patch(membro.funcionarioId, { gestorId: undefined });
return null;
},
await ctx.db.patch(membro.funcionarioId, { gestorId: undefined });
return null;
}
});
// Mutation: Transferir membro para outro time
export const transferirMembro = mutation({
args: {
funcionarioId: v.id("funcionarios"),
novoTimeId: v.id("times"),
},
returns: v.null(),
handler: async (ctx, args) => {
// Desativar do time atual
const relacaoAtual = await ctx.db
.query("timesMembros")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", args.funcionarioId))
.filter((q) => q.eq(q.field("ativo"), true))
.first();
if (relacaoAtual) {
await ctx.db.patch(relacaoAtual._id, {
ativo: false,
dataSaida: Date.now(),
});
}
// Adicionar ao novo time
const novoTime = await ctx.db.get(args.novoTimeId);
if (!novoTime || !novoTime.ativo) {
throw new Error("Novo time inválido ou inativo");
}
args: {
funcionarioId: v.id('funcionarios'),
novoTimeId: v.id('times')
},
returns: v.null(),
handler: async (ctx, args) => {
// Desativar do time atual
const relacaoAtual = await ctx.db
.query('timesMembros')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
.filter((q) => q.eq(q.field('ativo'), true))
.first();
await ctx.db.insert("timesMembros", {
timeId: args.novoTimeId,
funcionarioId: args.funcionarioId,
dataEntrada: Date.now(),
ativo: true,
});
if (relacaoAtual) {
await ctx.db.patch(relacaoAtual._id, {
ativo: false,
dataSaida: Date.now()
});
}
await ctx.db.patch(args.funcionarioId, { gestorId: novoTime.gestorId });
return null;
},
// Adicionar ao novo time
const novoTime = await ctx.db.get(args.novoTimeId);
if (!novoTime || !novoTime.ativo) {
throw new Error('Novo time inválido ou inativo');
}
await ctx.db.insert('timesMembros', {
timeId: args.novoTimeId,
funcionarioId: args.funcionarioId,
dataEntrada: Date.now(),
ativo: true
});
await ctx.db.patch(args.funcionarioId, { gestorId: novoTime.gestorId });
return null;
}
});

View File

@@ -14,19 +14,12 @@
"types": [],
/* These compiler options are required by Convex */
"target": "ESNext",
"lib": [
"ES2021",
"dom"
],
"lib": ["ES2021", "dom"],
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"isolatedModules": true,
"noEmit": true
},
"include": [
"./**/*"
],
"exclude": [
"./_generated"
]
}
"include": ["./**/*"],
"exclude": ["./_generated"]
}

View File

@@ -1,46 +1,41 @@
declare module "web-push" {
export interface PushSubscription {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}
declare module 'web-push' {
export interface PushSubscription {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}
export interface SendOptions {
TTL?: number;
headers?: Record<string, string>;
vapidDetails?: {
subject: string;
publicKey: string;
privateKey: string;
};
}
export interface SendOptions {
TTL?: number;
headers?: Record<string, string>;
vapidDetails?: {
subject: string;
publicKey: string;
privateKey: string;
};
}
export function setVapidDetails(
subject: string,
publicKey: string,
privateKey: string
): void;
export function setVapidDetails(subject: string, publicKey: string, privateKey: string): void;
export function sendNotification(
subscription: PushSubscription,
payload: string | Buffer,
options?: SendOptions
): Promise<void>;
export function sendNotification(
subscription: PushSubscription,
payload: string | Buffer,
options?: SendOptions
): Promise<void>;
export function generateVAPIDKeys(): {
publicKey: string;
privateKey: string;
};
export function generateVAPIDKeys(): {
publicKey: string;
privateKey: string;
};
interface WebPush {
setVapidDetails: typeof setVapidDetails;
sendNotification: typeof sendNotification;
generateVAPIDKeys: typeof generateVAPIDKeys;
}
interface WebPush {
setVapidDetails: typeof setVapidDetails;
sendNotification: typeof sendNotification;
generateVAPIDKeys: typeof generateVAPIDKeys;
}
const webpush: WebPush;
export default webpush;
const webpush: WebPush;
export default webpush;
}

View File

@@ -559,7 +559,7 @@ export const atualizarTema = mutation({
*/
export const obterPerfil = query({
args: {},
returns: v.union(
returns: v.union(
v.object({
_id: v.id('usuarios'),
nome: v.string(),
@@ -624,7 +624,7 @@ export const obterPerfil = query({
*/
export const listarParaChat = query({
args: {},
returns: v.array(
returns: v.array(
v.object({
_id: v.id('usuarios'),
nome: v.string(),

View File

@@ -9,17 +9,17 @@
* @returns Mensagem formatada
*/
export function wrapChatMessage(conteudo: string, tipo?: string): string {
// Se já tiver formatação especial, retornar como está
if (conteudo.includes('[SGSE]') || conteudo.includes('[Sistema]')) {
return conteudo;
}
// Se já tiver formatação especial, retornar como está
if (conteudo.includes('[SGSE]') || conteudo.includes('[Sistema]')) {
return conteudo;
}
// Para mensagens do sistema, adicionar prefixo
if (tipo === 'sistema' || tipo === 'notificacao') {
return `[SGSE] ${conteudo}`;
}
// Para mensagens do sistema, adicionar prefixo
if (tipo === 'sistema' || tipo === 'notificacao') {
return `[SGSE] ${conteudo}`;
}
return conteudo;
return conteudo;
}
/**
@@ -29,18 +29,12 @@ export function wrapChatMessage(conteudo: string, tipo?: string): string {
* @param acao - Ação sugerida (opcional)
* @returns Mensagem formatada
*/
export function formatChatNotification(
titulo: string,
conteudo: string,
acao?: string
): string {
let mensagem = `🔔 ${titulo}\n\n${conteudo}`;
if (acao) {
mensagem += `\n\n💡 ${acao}`;
}
return mensagem;
export function formatChatNotification(titulo: string, conteudo: string, acao?: string): string {
let mensagem = `🔔 ${titulo}\n\n${conteudo}`;
if (acao) {
mensagem += `\n\n💡 ${acao}`;
}
return mensagem;
}

View File

@@ -7,21 +7,21 @@
* Obtém a URL base do sistema para uso em links de email
*/
function getBaseUrl(): string {
// Em produção, usar variável de ambiente
const url = process.env.FRONTEND_URL || "http://localhost:5173";
// Garantir que tenha protocolo
if (!url.match(/^https?:\/\//i)) {
return `http://${url}`;
}
return url;
// Em produção, usar variável de ambiente
const url = process.env.FRONTEND_URL || 'http://localhost:5173';
// Garantir que tenha protocolo
if (!url.match(/^https?:\/\//i)) {
return `http://${url}`;
}
return url;
}
/**
* Gera o HTML do header com logo do Governo de PE
*/
function generateHeader(): string {
const baseUrl = getBaseUrl();
return `
const baseUrl = getBaseUrl();
return `
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #1a3a52; padding: 20px 0;">
<tr>
<td align="center">
@@ -42,10 +42,10 @@ function generateHeader(): string {
* Gera o HTML do footer com assinatura SGSE
*/
function generateFooter(): string {
const baseUrl = getBaseUrl();
const currentYear = new Date().getFullYear();
return `
const baseUrl = getBaseUrl();
const currentYear = new Date().getFullYear();
return `
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f5f5; border-top: 3px solid #1a3a52; margin-top: 30px;">
<tr>
<td align="center">
@@ -85,24 +85,24 @@ function generateFooter(): string {
* @returns HTML completo do email pronto para envio
*/
export function wrapEmailHTML(conteudoHTML: string, titulo?: string): string {
// Se o conteúdo já estiver dentro de um wrapper completo, retornar como está
if (conteudoHTML.includes('<!DOCTYPE html>') || conteudoHTML.includes('<html')) {
return conteudoHTML;
}
// Se o conteúdo já estiver dentro de um wrapper completo, retornar como está
if (conteudoHTML.includes('<!DOCTYPE html>') || conteudoHTML.includes('<html')) {
return conteudoHTML;
}
// Garantir que o conteúdo tenha estrutura básica
let conteudoProcessado = conteudoHTML.trim();
// Se não tiver tags HTML básicas, envolver em parágrafo
if (!conteudoProcessado.match(/^<[a-z]/i)) {
conteudoProcessado = `<p style="margin: 0 0 15px 0;">${conteudoProcessado}</p>`;
}
// Garantir que o conteúdo tenha estrutura básica
let conteudoProcessado = conteudoHTML.trim();
const header = generateHeader();
const footer = generateFooter();
const emailTitle = titulo || "Notificação do SGSE";
// Se não tiver tags HTML básicas, envolver em parágrafo
if (!conteudoProcessado.match(/^<[a-z]/i)) {
conteudoProcessado = `<p style="margin: 0 0 15px 0;">${conteudoProcessado}</p>`;
}
return `
const header = generateHeader();
const footer = generateFooter();
const emailTitle = titulo || 'Notificação do SGSE';
return `
<!DOCTYPE html>
<html lang="pt-BR">
<head>
@@ -169,17 +169,18 @@ export function wrapEmailHTML(conteudoHTML: string, titulo?: string): string {
* @returns HTML formatado
*/
export function textToHTML(texto: string): string {
return texto
.split('\n')
.map(linha => {
const linhaTrim = linha.trim();
if (!linhaTrim) return '<br />';
// Detectar links
const linkRegex = /(https?:\/\/[^\s]+)/g;
const linhaComLinks = linhaTrim.replace(linkRegex, '<a href="$1" style="color: #1a3a52; text-decoration: underline;">$1</a>');
return `<p style="margin: 0 0 15px 0;">${linhaComLinks}</p>`;
})
.join('');
return texto
.split('\n')
.map((linha) => {
const linhaTrim = linha.trim();
if (!linhaTrim) return '<br />';
// Detectar links
const linkRegex = /(https?:\/\/[^\s]+)/g;
const linhaComLinks = linhaTrim.replace(
linkRegex,
'<a href="$1" style="color: #1a3a52; text-decoration: underline;">$1</a>'
);
return `<p style="margin: 0 0 15px 0;">${linhaComLinks}</p>`;
})
.join('');
}

View File

@@ -8,144 +8,143 @@
* Considera headers como X-Forwarded-For, X-Real-IP, etc.
*/
export function getClientIP(request: Request): string | undefined {
// Headers que podem conter o IP do cliente (case-insensitive)
const getHeader = (name: string): string | null => {
// Tentar diferentes variações de case
const variations = [
name,
name.toLowerCase(),
name.toUpperCase(),
name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(),
];
for (const variation of variations) {
const value = request.headers.get(variation);
if (value) return value;
}
// As variações de case já cobrem a maioria dos casos
// Se não encontrou, retorna null
return null;
};
const forwardedFor = getHeader("x-forwarded-for");
const realIP = getHeader("x-real-ip");
const cfConnectingIP = getHeader("cf-connecting-ip"); // Cloudflare
const trueClientIP = getHeader("true-client-ip"); // Cloudflare Enterprise
const xClientIP = getHeader("x-client-ip");
const forwarded = getHeader("forwarded");
const remoteAddr = getHeader("remote-addr");
// Log para debug
console.log("Procurando IP nos headers:", {
"x-forwarded-for": forwardedFor,
"x-real-ip": realIP,
"cf-connecting-ip": cfConnectingIP,
"true-client-ip": trueClientIP,
"x-client-ip": xClientIP,
"forwarded": forwarded,
"remote-addr": remoteAddr,
});
// Prioridade: X-Forwarded-For pode conter múltiplos IPs (proxy chain)
// O primeiro IP é geralmente o IP original do cliente
if (forwardedFor) {
const ips = forwardedFor.split(",").map((ip) => ip.trim());
// Pegar o primeiro IP válido
for (const ip of ips) {
if (isValidIP(ip)) {
console.log("IP encontrado em X-Forwarded-For:", ip);
return ip;
}
}
}
// Forwarded header (RFC 7239)
if (forwarded) {
// Formato: for=192.0.2.60;proto=http;by=203.0.113.43
const forMatch = forwarded.match(/for=([^;,\s]+)/i);
if (forMatch && forMatch[1]) {
const ip = forMatch[1].replace(/^\[|\]$/g, ''); // Remove brackets de IPv6
if (isValidIP(ip)) {
console.log("IP encontrado em Forwarded:", ip);
return ip;
}
}
}
// Outros headers com IP único
if (realIP && isValidIP(realIP)) {
console.log("IP encontrado em X-Real-IP:", realIP);
return realIP;
}
if (cfConnectingIP && isValidIP(cfConnectingIP)) {
console.log("IP encontrado em CF-Connecting-IP:", cfConnectingIP);
return cfConnectingIP;
}
if (trueClientIP && isValidIP(trueClientIP)) {
console.log("IP encontrado em True-Client-IP:", trueClientIP);
return trueClientIP;
}
if (xClientIP && isValidIP(xClientIP)) {
console.log("IP encontrado em X-Client-IP:", xClientIP);
return xClientIP;
}
if (remoteAddr && isValidIP(remoteAddr)) {
console.log("IP encontrado em Remote-Addr:", remoteAddr);
return remoteAddr;
}
// Tentar extrair do URL (último recurso)
try {
const url = new URL(request.url);
// Se o servidor estiver configurado para passar IP via query param
const ipFromQuery = url.searchParams.get("ip");
if (ipFromQuery && isValidIP(ipFromQuery)) {
console.log("IP encontrado em query param:", ipFromQuery);
return ipFromQuery;
}
} catch {
// Ignorar erro de parsing do URL
}
console.log("Nenhum IP válido encontrado nos headers");
return undefined;
// Headers que podem conter o IP do cliente (case-insensitive)
const getHeader = (name: string): string | null => {
// Tentar diferentes variações de case
const variations = [
name,
name.toLowerCase(),
name.toUpperCase(),
name.charAt(0).toUpperCase() + name.slice(1).toLowerCase()
];
for (const variation of variations) {
const value = request.headers.get(variation);
if (value) return value;
}
// As variações de case já cobrem a maioria dos casos
// Se não encontrou, retorna null
return null;
};
const forwardedFor = getHeader('x-forwarded-for');
const realIP = getHeader('x-real-ip');
const cfConnectingIP = getHeader('cf-connecting-ip'); // Cloudflare
const trueClientIP = getHeader('true-client-ip'); // Cloudflare Enterprise
const xClientIP = getHeader('x-client-ip');
const forwarded = getHeader('forwarded');
const remoteAddr = getHeader('remote-addr');
// Log para debug
console.log('Procurando IP nos headers:', {
'x-forwarded-for': forwardedFor,
'x-real-ip': realIP,
'cf-connecting-ip': cfConnectingIP,
'true-client-ip': trueClientIP,
'x-client-ip': xClientIP,
forwarded: forwarded,
'remote-addr': remoteAddr
});
// Prioridade: X-Forwarded-For pode conter múltiplos IPs (proxy chain)
// O primeiro IP é geralmente o IP original do cliente
if (forwardedFor) {
const ips = forwardedFor.split(',').map((ip) => ip.trim());
// Pegar o primeiro IP válido
for (const ip of ips) {
if (isValidIP(ip)) {
console.log('IP encontrado em X-Forwarded-For:', ip);
return ip;
}
}
}
// Forwarded header (RFC 7239)
if (forwarded) {
// Formato: for=192.0.2.60;proto=http;by=203.0.113.43
const forMatch = forwarded.match(/for=([^;,\s]+)/i);
if (forMatch && forMatch[1]) {
const ip = forMatch[1].replace(/^\[|\]$/g, ''); // Remove brackets de IPv6
if (isValidIP(ip)) {
console.log('IP encontrado em Forwarded:', ip);
return ip;
}
}
}
// Outros headers com IP único
if (realIP && isValidIP(realIP)) {
console.log('IP encontrado em X-Real-IP:', realIP);
return realIP;
}
if (cfConnectingIP && isValidIP(cfConnectingIP)) {
console.log('IP encontrado em CF-Connecting-IP:', cfConnectingIP);
return cfConnectingIP;
}
if (trueClientIP && isValidIP(trueClientIP)) {
console.log('IP encontrado em True-Client-IP:', trueClientIP);
return trueClientIP;
}
if (xClientIP && isValidIP(xClientIP)) {
console.log('IP encontrado em X-Client-IP:', xClientIP);
return xClientIP;
}
if (remoteAddr && isValidIP(remoteAddr)) {
console.log('IP encontrado em Remote-Addr:', remoteAddr);
return remoteAddr;
}
// Tentar extrair do URL (último recurso)
try {
const url = new URL(request.url);
// Se o servidor estiver configurado para passar IP via query param
const ipFromQuery = url.searchParams.get('ip');
if (ipFromQuery && isValidIP(ipFromQuery)) {
console.log('IP encontrado em query param:', ipFromQuery);
return ipFromQuery;
}
} catch {
// Ignorar erro de parsing do URL
}
console.log('Nenhum IP válido encontrado nos headers');
return undefined;
}
/**
* Valida se uma string é um endereço IP válido (IPv4 ou IPv6)
*/
function isValidIP(ip: string): boolean {
if (!ip || ip.length === 0) {
return false;
}
// Validar IPv4
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (ipv4Regex.test(ip)) {
const parts = ip.split(".");
return parts.every((part) => {
const num = parseInt(part, 10);
return num >= 0 && num <= 255;
});
}
// Validar IPv6 (formato simplificado)
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
if (ipv6Regex.test(ip)) {
return true;
}
// Validar IPv6 comprimido (com ::)
const ipv6CompressedRegex = /^([0-9a-fA-F]{0,4}:)*::([0-9a-fA-F]{0,4}:)*[0-9a-fA-F]{0,4}$/;
if (ipv6CompressedRegex.test(ip)) {
return true;
}
return false;
}
if (!ip || ip.length === 0) {
return false;
}
// Validar IPv4
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (ipv4Regex.test(ip)) {
const parts = ip.split('.');
return parts.every((part) => {
const num = parseInt(part, 10);
return num >= 0 && num <= 255;
});
}
// Validar IPv6 (formato simplificado)
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
if (ipv6Regex.test(ip)) {
return true;
}
// Validar IPv6 comprimido (com ::)
const ipv6CompressedRegex = /^([0-9a-fA-F]{0,4}:)*::([0-9a-fA-F]{0,4}:)*[0-9a-fA-F]{0,4}$/;
if (ipv6CompressedRegex.test(ip)) {
return true;
}
return false;
}

View File

@@ -3,18 +3,18 @@
* Identifica todos os locais onde emails são enviados para gerar templates
*/
import { Doc } from "../_generated/dataModel";
import { Doc } from '../_generated/dataModel';
export interface EmailSendLocation {
arquivo: string;
funcao: string;
tipo: "enfileirarEmail" | "enviarEmailComTemplate" | "enviarMensagem" | "html_inline";
linha?: number;
contexto?: string;
assunto?: string;
corpo?: string;
templateCodigo?: string;
variaveis?: string[];
arquivo: string;
funcao: string;
tipo: 'enfileirarEmail' | 'enviarEmailComTemplate' | 'enviarMensagem' | 'html_inline';
linha?: number;
contexto?: string;
assunto?: string;
corpo?: string;
templateCodigo?: string;
variaveis?: string[];
}
/**
@@ -22,168 +22,187 @@ export interface EmailSendLocation {
* Este é um mapeamento manual baseado na análise do código
*/
export const LOCAIS_ENVIO_EMAIL: EmailSendLocation[] = [
// Chamados
{
arquivo: "packages/backend/convex/chamados.ts",
funcao: "registrarNotificacoes",
tipo: "enfileirarEmail",
contexto: "Notificação ao solicitante quando chamado é criado/atualizado",
assunto: "Chamado {{numeroTicket}} - {{titulo}}",
corpo: "{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria",
variaveis: ["numeroTicket", "titulo", "mensagem"],
},
{
arquivo: "packages/backend/convex/chamados.ts",
funcao: "registrarNotificacoes",
tipo: "enfileirarEmail",
contexto: "Notificação ao responsável quando chamado é atualizado",
assunto: "Chamado {{numeroTicket}} - {{titulo}}",
corpo: "{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria",
variaveis: ["numeroTicket", "titulo", "mensagem"],
},
// Ausências
{
arquivo: "packages/backend/convex/ausencias.ts",
funcao: "solicitar",
tipo: "enfileirarEmail",
contexto: "Notificação ao gestor quando funcionário solicita ausência",
assunto: "Nova Solicitação de Ausência - {{funcionarioNome}}",
corpo: "Olá {{gestorNome}},\n\nO funcionário <strong>{{funcionarioNome}}</strong> solicitou uma ausência:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>\n\nPor favor, acesse o sistema para aprovar ou reprovar esta solicitação.",
variaveis: ["gestorNome", "funcionarioNome", "dataInicio", "dataFim", "motivo"],
},
{
arquivo: "packages/backend/convex/ausencias.ts",
funcao: "aprovar",
tipo: "enfileirarEmail",
contexto: "Notificação ao funcionário quando ausência é aprovada",
assunto: "Solicitação de Ausência Aprovada",
corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>aprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>",
variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo"],
},
{
arquivo: "packages/backend/convex/ausencias.ts",
funcao: "reprovar",
tipo: "enfileirarEmail",
contexto: "Notificação ao funcionário quando ausência é reprovada",
assunto: "Solicitação de Ausência Reprovada",
corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>reprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li><li><strong>Motivo da Reprovação:</strong> {{motivoReprovacao}}</li></ul>",
variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "motivoReprovacao"],
},
// Chat
{
arquivo: "packages/backend/convex/chat.ts",
funcao: "enviarMensagem",
tipo: "enviarEmailComTemplate",
contexto: "Email quando usuário recebe nova mensagem no chat (usuário offline)",
templateCodigo: "chat_mensagem",
variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
},
{
arquivo: "packages/backend/convex/chat.ts",
funcao: "enviarMensagem",
tipo: "enviarEmailComTemplate",
contexto: "Email quando usuário é mencionado no chat (usuário offline)",
templateCodigo: "chat_mencao",
variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
},
// Painel de Notificações
{
arquivo: "apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte",
funcao: "enviarNotificacao",
tipo: "enfileirarEmail",
contexto: "Envio manual de notificação via painel de TI",
assunto: "Notificação do Sistema",
corpo: "{{mensagemPersonalizada}}",
variaveis: ["mensagemPersonalizada"],
},
{
arquivo: "apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte",
funcao: "enviarNotificacao",
tipo: "enviarEmailComTemplate",
contexto: "Envio manual de notificação usando template via painel de TI",
templateCodigo: "{{templateCodigo}}",
variaveis: ["nome", "matricula"],
},
// Chamados
{
arquivo: 'packages/backend/convex/chamados.ts',
funcao: 'registrarNotificacoes',
tipo: 'enfileirarEmail',
contexto: 'Notificação ao solicitante quando chamado é criado/atualizado',
assunto: 'Chamado {{numeroTicket}} - {{titulo}}',
corpo: '{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria',
variaveis: ['numeroTicket', 'titulo', 'mensagem']
},
{
arquivo: 'packages/backend/convex/chamados.ts',
funcao: 'registrarNotificacoes',
tipo: 'enfileirarEmail',
contexto: 'Notificação ao responsável quando chamado é atualizado',
assunto: 'Chamado {{numeroTicket}} - {{titulo}}',
corpo: '{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria',
variaveis: ['numeroTicket', 'titulo', 'mensagem']
},
// Ausências
{
arquivo: 'packages/backend/convex/ausencias.ts',
funcao: 'solicitar',
tipo: 'enfileirarEmail',
contexto: 'Notificação ao gestor quando funcionário solicita ausência',
assunto: 'Nova Solicitação de Ausência - {{funcionarioNome}}',
corpo:
'Olá {{gestorNome}},\n\nO funcionário <strong>{{funcionarioNome}}</strong> solicitou uma ausência:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>\n\nPor favor, acesse o sistema para aprovar ou reprovar esta solicitação.',
variaveis: ['gestorNome', 'funcionarioNome', 'dataInicio', 'dataFim', 'motivo']
},
{
arquivo: 'packages/backend/convex/ausencias.ts',
funcao: 'aprovar',
tipo: 'enfileirarEmail',
contexto: 'Notificação ao funcionário quando ausência é aprovada',
assunto: 'Solicitação de Ausência Aprovada',
corpo:
'Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>aprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>',
variaveis: ['funcionarioNome', 'gestorNome', 'dataInicio', 'dataFim', 'motivo']
},
{
arquivo: 'packages/backend/convex/ausencias.ts',
funcao: 'reprovar',
tipo: 'enfileirarEmail',
contexto: 'Notificação ao funcionário quando ausência é reprovada',
assunto: 'Solicitação de Ausência Reprovada',
corpo:
'Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>reprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li><li><strong>Motivo da Reprovação:</strong> {{motivoReprovacao}}</li></ul>',
variaveis: [
'funcionarioNome',
'gestorNome',
'dataInicio',
'dataFim',
'motivo',
'motivoReprovacao'
]
},
// Chat
{
arquivo: 'packages/backend/convex/chat.ts',
funcao: 'enviarMensagem',
tipo: 'enviarEmailComTemplate',
contexto: 'Email quando usuário recebe nova mensagem no chat (usuário offline)',
templateCodigo: 'chat_mensagem',
variaveis: ['remetente', 'mensagem', 'conversaId', 'urlSistema']
},
{
arquivo: 'packages/backend/convex/chat.ts',
funcao: 'enviarMensagem',
tipo: 'enviarEmailComTemplate',
contexto: 'Email quando usuário é mencionado no chat (usuário offline)',
templateCodigo: 'chat_mencao',
variaveis: ['remetente', 'mensagem', 'conversaId', 'urlSistema']
},
// Painel de Notificações
{
arquivo: 'apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte',
funcao: 'enviarNotificacao',
tipo: 'enfileirarEmail',
contexto: 'Envio manual de notificação via painel de TI',
assunto: 'Notificação do Sistema',
corpo: '{{mensagemPersonalizada}}',
variaveis: ['mensagemPersonalizada']
},
{
arquivo: 'apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte',
funcao: 'enviarNotificacao',
tipo: 'enviarEmailComTemplate',
contexto: 'Envio manual de notificação usando template via painel de TI',
templateCodigo: '{{templateCodigo}}',
variaveis: ['nome', 'matricula']
}
];
/**
* Sugestões de templates baseadas nos locais de envio encontrados
*/
export interface TemplateSuggestion {
codigo: string;
nome: string;
titulo: string;
corpo: string;
categoria: "email" | "chat" | "ambos";
variaveis: string[];
tags: string[];
origem: string;
codigo: string;
nome: string;
titulo: string;
corpo: string;
categoria: 'email' | 'chat' | 'ambos';
variaveis: string[];
tags: string[];
origem: string;
}
/**
* Gerar sugestões de templates baseadas nos locais de envio
*/
export function gerarSugestoesTemplates(): TemplateSuggestion[] {
const sugestoes: TemplateSuggestion[] = [];
const sugestoes: TemplateSuggestion[] = [];
// Template para ausência solicitada
sugestoes.push({
codigo: "ausencia_solicitada",
nome: "Ausência Solicitada",
titulo: "Nova Solicitação de Ausência - {{funcionarioNome}}",
corpo: "Olá {{gestorNome}},\n\nO funcionário <strong>{{funcionarioNome}}</strong> solicitou uma ausência:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>\n\nPor favor, acesse o sistema para aprovar ou reprovar esta solicitação.",
categoria: "email",
variaveis: ["gestorNome", "funcionarioNome", "dataInicio", "dataFim", "motivo"],
tags: ["ausencia", "solicitacao", "gestao"],
origem: "ausencias.ts - solicitar",
});
// Template para ausência solicitada
sugestoes.push({
codigo: 'ausencia_solicitada',
nome: 'Ausência Solicitada',
titulo: 'Nova Solicitação de Ausência - {{funcionarioNome}}',
corpo:
'Olá {{gestorNome}},\n\nO funcionário <strong>{{funcionarioNome}}</strong> solicitou uma ausência:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>\n\nPor favor, acesse o sistema para aprovar ou reprovar esta solicitação.',
categoria: 'email',
variaveis: ['gestorNome', 'funcionarioNome', 'dataInicio', 'dataFim', 'motivo'],
tags: ['ausencia', 'solicitacao', 'gestao'],
origem: 'ausencias.ts - solicitar'
});
// Template para ausência aprovada
sugestoes.push({
codigo: "ausencia_aprovada",
nome: "Ausência Aprovada",
titulo: "Solicitação de Ausência Aprovada",
corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>aprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>",
categoria: "email",
variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo"],
tags: ["ausencia", "aprovacao", "gestao"],
origem: "ausencias.ts - aprovar",
});
// Template para ausência aprovada
sugestoes.push({
codigo: 'ausencia_aprovada',
nome: 'Ausência Aprovada',
titulo: 'Solicitação de Ausência Aprovada',
corpo:
'Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>aprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>',
categoria: 'email',
variaveis: ['funcionarioNome', 'gestorNome', 'dataInicio', 'dataFim', 'motivo'],
tags: ['ausencia', 'aprovacao', 'gestao'],
origem: 'ausencias.ts - aprovar'
});
// Template para ausência reprovada
sugestoes.push({
codigo: "ausencia_reprovada",
nome: "Ausência Reprovada",
titulo: "Solicitação de Ausência Reprovada",
corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>reprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li><li><strong>Motivo da Reprovação:</strong> {{motivoReprovacao}}</li></ul>",
categoria: "email",
variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "motivoReprovacao"],
tags: ["ausencia", "reprovacao", "gestao"],
origem: "ausencias.ts - reprovar",
});
// Template para ausência reprovada
sugestoes.push({
codigo: 'ausencia_reprovada',
nome: 'Ausência Reprovada',
titulo: 'Solicitação de Ausência Reprovada',
corpo:
'Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>reprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li><li><strong>Motivo da Reprovação:</strong> {{motivoReprovacao}}</li></ul>',
categoria: 'email',
variaveis: [
'funcionarioNome',
'gestorNome',
'dataInicio',
'dataFim',
'motivo',
'motivoReprovacao'
],
tags: ['ausencia', 'reprovacao', 'gestao'],
origem: 'ausencias.ts - reprovar'
});
// Template genérico para notificações de chamados
sugestoes.push({
codigo: "chamado_notificacao",
nome: "Notificação de Chamado",
titulo: "Chamado {{numeroTicket}} - {{titulo}}",
corpo: "{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria",
categoria: "email",
variaveis: ["numeroTicket", "titulo", "mensagem"],
tags: ["chamado", "notificacao", "suporte"],
origem: "chamados.ts - registrarNotificacoes",
});
// Template genérico para notificações de chamados
sugestoes.push({
codigo: 'chamado_notificacao',
nome: 'Notificação de Chamado',
titulo: 'Chamado {{numeroTicket}} - {{titulo}}',
corpo: '{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria',
categoria: 'email',
variaveis: ['numeroTicket', 'titulo', 'mensagem'],
tags: ['chamado', 'notificacao', 'suporte'],
origem: 'chamados.ts - registrarNotificacoes'
});
return sugestoes;
return sugestoes;
}
/**
* Obter todos os locais de envio de email
*/
export function obterLocaisEnvio(): EmailSendLocation[] {
return LOCAIS_ENVIO_EMAIL;
return LOCAIS_ENVIO_EMAIL;
}

View File

@@ -1,121 +1,123 @@
import { internalMutation, query } from "./_generated/server";
import { v } from "convex/values";
import { Id, Doc } from "./_generated/dataModel";
import { internalMutation, query } from './_generated/server';
import { v } from 'convex/values';
import { Id, Doc } from './_generated/dataModel';
/**
* Verificar duplicatas de matrícula (agora busca do funcionário associado)
*/
export const verificarDuplicatas = query({
args: {},
returns: v.array(
v.object({
matricula: v.string(),
count: v.number(),
usuarios: v.array(
v.object({
_id: v.id("usuarios"),
nome: v.string(),
email: v.string(),
})
),
})
),
handler: async (ctx) => {
const usuarios = await ctx.db.query("usuarios").collect();
// Agrupar por matrícula do funcionário associado
const gruposPorMatricula: Record<string, Array<{ _id: Id<"usuarios">; nome: string; email: string }>> = {};
for (const usuario of usuarios) {
let matricula: string | undefined = undefined;
if (usuario.funcionarioId) {
const funcionario = await ctx.db.get(usuario.funcionarioId);
matricula = funcionario?.matricula;
}
if (matricula) {
if (!gruposPorMatricula[matricula]) {
gruposPorMatricula[matricula] = [];
}
gruposPorMatricula[matricula].push({
_id: usuario._id,
nome: usuario.nome,
email: usuario.email || "",
});
}
}
// Filtrar apenas duplicatas
const duplicatas = Object.entries(gruposPorMatricula)
.filter(([_, usuarios]) => usuarios.length > 1)
.map(([matricula, usuarios]) => ({
matricula,
count: usuarios.length,
usuarios,
}));
return duplicatas;
},
args: {},
returns: v.array(
v.object({
matricula: v.string(),
count: v.number(),
usuarios: v.array(
v.object({
_id: v.id('usuarios'),
nome: v.string(),
email: v.string()
})
)
})
),
handler: async (ctx) => {
const usuarios = await ctx.db.query('usuarios').collect();
// Agrupar por matrícula do funcionário associado
const gruposPorMatricula: Record<
string,
Array<{ _id: Id<'usuarios'>; nome: string; email: string }>
> = {};
for (const usuario of usuarios) {
let matricula: string | undefined = undefined;
if (usuario.funcionarioId) {
const funcionario = await ctx.db.get(usuario.funcionarioId);
matricula = funcionario?.matricula;
}
if (matricula) {
if (!gruposPorMatricula[matricula]) {
gruposPorMatricula[matricula] = [];
}
gruposPorMatricula[matricula].push({
_id: usuario._id,
nome: usuario.nome,
email: usuario.email || ''
});
}
}
// Filtrar apenas duplicatas
const duplicatas = Object.entries(gruposPorMatricula)
.filter(([_, usuarios]) => usuarios.length > 1)
.map(([matricula, usuarios]) => ({
matricula,
count: usuarios.length,
usuarios
}));
return duplicatas;
}
});
/**
* Remover duplicatas mantendo apenas o mais recente (agora busca do funcionário associado)
*/
export const removerDuplicatas = internalMutation({
args: {},
returns: v.object({
removidos: v.number(),
matriculas: v.array(v.string()),
}),
handler: async (ctx) => {
const usuarios = await ctx.db.query("usuarios").collect();
// Agrupar por matrícula do funcionário associado
const gruposPorMatricula: Record<string, Doc<"usuarios">[]> = {};
for (const usuario of usuarios) {
let matricula: string | undefined = undefined;
if (usuario.funcionarioId) {
const funcionario = await ctx.db.get(usuario.funcionarioId);
matricula = funcionario?.matricula;
}
if (matricula) {
if (!gruposPorMatricula[matricula]) {
gruposPorMatricula[matricula] = [];
}
gruposPorMatricula[matricula].push(usuario);
}
}
let removidos = 0;
const matriculasDuplicadas: string[] = [];
// Para cada grupo com duplicatas
for (const [matricula, usuariosGrupo] of Object.entries(gruposPorMatricula)) {
if (usuariosGrupo.length > 1) {
matriculasDuplicadas.push(matricula);
// Ordenar por _creationTime (mais recente primeiro)
usuariosGrupo.sort((a, b) => b._creationTime - a._creationTime);
// Manter o primeiro (mais recente) e remover os outros
for (let i = 1; i < usuariosGrupo.length; i++) {
await ctx.db.delete(usuariosGrupo[i]._id);
removidos++;
console.log(`🗑️ Removido usuário duplicado: ${usuariosGrupo[i].nome} (matrícula: ${matricula})`);
}
console.log(`✅ Mantido usuário: ${usuariosGrupo[0].nome} (matrícula: ${matricula})`);
}
}
return {
removidos,
matriculas: matriculasDuplicadas,
};
},
args: {},
returns: v.object({
removidos: v.number(),
matriculas: v.array(v.string())
}),
handler: async (ctx) => {
const usuarios = await ctx.db.query('usuarios').collect();
// Agrupar por matrícula do funcionário associado
const gruposPorMatricula: Record<string, Doc<'usuarios'>[]> = {};
for (const usuario of usuarios) {
let matricula: string | undefined = undefined;
if (usuario.funcionarioId) {
const funcionario = await ctx.db.get(usuario.funcionarioId);
matricula = funcionario?.matricula;
}
if (matricula) {
if (!gruposPorMatricula[matricula]) {
gruposPorMatricula[matricula] = [];
}
gruposPorMatricula[matricula].push(usuario);
}
}
let removidos = 0;
const matriculasDuplicadas: string[] = [];
// Para cada grupo com duplicatas
for (const [matricula, usuariosGrupo] of Object.entries(gruposPorMatricula)) {
if (usuariosGrupo.length > 1) {
matriculasDuplicadas.push(matricula);
// Ordenar por _creationTime (mais recente primeiro)
usuariosGrupo.sort((a, b) => b._creationTime - a._creationTime);
// Manter o primeiro (mais recente) e remover os outros
for (let i = 1; i < usuariosGrupo.length; i++) {
await ctx.db.delete(usuariosGrupo[i]._id);
removidos++;
console.log(
`🗑️ Removido usuário duplicado: ${usuariosGrupo[i].nome} (matrícula: ${matricula})`
);
}
console.log(`✅ Mantido usuário: ${usuariosGrupo[0].nome} (matrícula: ${matricula})`);
}
}
return {
removidos,
matriculas: matriculasDuplicadas
};
}
});

View File

@@ -1,7 +1,5 @@
import { config } from '@sgse-app/eslint-config/convex';
import { defineConfig } from 'eslint/config';
/** @type {import("eslint").Linter.Config} */
export default defineConfig([
...config,
]);
export default defineConfig([...config]);

View File

@@ -1,33 +1,35 @@
{
"name": "@sgse-app/backend",
"version": "1.0.0",
"scripts": {
"dev": "convex dev",
"dev:setup": "convex dev --configure --until-success"
},
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@sgse-app/eslint-config": "*",
"@types/cookie": "^1.0.0",
"@types/estree": "^1.0.8",
"@types/nodemailer": "^7.0.3",
"@types/json-schema": "^7.0.15",
"@types/node": "^24.3.0",
"@types/pako": "^2.0.4",
"@types/raf": "^3.4.3",
"@types/trusted-types": "^2.0.7",
"typescript": "catalog:",
"eslint": "catalog:"
},
"dependencies": {
"@convex-dev/better-auth": "^0.9.7",
"@convex-dev/rate-limiter": "^0.3.0",
"@dicebear/avataaars": "^9.2.4",
"@types/ssh2": "^1.15.5",
"better-auth": "catalog:",
"convex": "catalog:",
"nodemailer": "^7.0.10"
}
}
"name": "@sgse-app/backend",
"version": "1.0.0",
"scripts": {
"dev": "convex dev",
"dev:setup": "convex dev --configure --until-success",
"lint": "eslint .",
"format": "prettier --write ."
},
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@sgse-app/eslint-config": "*",
"@types/cookie": "^1.0.0",
"@types/estree": "^1.0.8",
"@types/nodemailer": "^7.0.3",
"@types/json-schema": "^7.0.15",
"@types/node": "^24.3.0",
"@types/pako": "^2.0.4",
"@types/raf": "^3.4.3",
"@types/trusted-types": "^2.0.7",
"typescript": "catalog:",
"eslint": "catalog:"
},
"dependencies": {
"@convex-dev/better-auth": "^0.9.7",
"@convex-dev/rate-limiter": "^0.3.0",
"@dicebear/avataaars": "^9.2.4",
"@types/ssh2": "^1.15.5",
"better-auth": "catalog:",
"convex": "catalog:",
"nodemailer": "^7.0.10"
}
}