Actualiza frontend, integraciones y dist de producción

This commit is contained in:
2026-07-31 10:53:28 -04:00
parent 364b313240
commit f65a56fc3c
25 changed files with 3876 additions and 436 deletions
+7 -1
View File
@@ -5,5 +5,11 @@ VITE_SUPABASE_URL=https://dbit.digitalcompass.agency
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_REEMPLAZAR VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_REEMPLAZAR
# VITE_SUPABASE_ANON_KEY=REEMPLAZAR_CON_ANON_KEY # VITE_SUPABASE_ANON_KEY=REEMPLAZAR_CON_ANON_KEY
# Webhook de producción de n8n. El frontend lo notifica después de guardar cambios. # Webhook del flujo de recordatorios, conservado como referencia de producción.
VITE_TAX_WEBHOOK_URL=https://agenteit.digitalcompass.agency/webhook/seguimiento-impuestos VITE_TAX_WEBHOOK_URL=https://agenteit.digitalcompass.agency/webhook/seguimiento-impuestos
# Busca empleados en BambooHR para completar contactos automáticamente.
VITE_BAMBOOHR_CONTACT_WEBHOOK_URL=https://agenteit.digitalcompass.agency/webhook/seguimiento-impuestos-bamboohr-contacto
# Sincroniza altas, cambios y eliminaciones con Google Calendar.
VITE_GOOGLE_CALENDAR_WEBHOOK_URL=https://agenteit.digitalcompass.agency/webhook/seguimiento-impuestos-google-calendar
+1 -1
View File
@@ -9,5 +9,5 @@
- Nunca hardcodear correos autorizados en React: se administran desde Supabase. - Nunca hardcodear correos autorizados en React: se administran desde Supabase.
- Nunca exponer la llave `service_role`; el frontend sólo utiliza una llave pública o `anon`. - Nunca exponer la llave `service_role`; el frontend sólo utiliza una llave pública o `anon`.
- El workflow de n8n consulta contactos y obligaciones directamente desde Supabase. - El workflow de n8n consulta contactos y obligaciones directamente desde Supabase.
- Mantener la base de Vite en `/calendario-impuestos/` salvo cambio explícito de la ruta pública. - Mantener la base de Vite en `/` porque la app se publica en la raíz de `https://home.digitalcompass.agency/`.
- Antes de entregar cambios, ejecutar `npm run typecheck`, `npm run lint` y `npm run build`. - Antes de entregar cambios, ejecutar `npm run typecheck`, `npm run lint` y `npm run build`.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

+9
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 767 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Seguimiento de Impuestos GLM</title>
<meta
name="description"
content="Calendario tributario GLM: recordatorios de obligaciones fiscales por país, con alertas del miércoles anterior y el mismo día."
/>
<meta name="author" content="GomezLee Marketing" />
<meta name="theme-color" content="#4F758B" />
<meta property="og:title" content="Seguimiento de Impuestos GLM" />
<meta
property="og:description"
content="Calendario tributario interno de GomezLee Marketing con recordatorios automáticos."
/>
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary" />
<link rel="icon" type="image/png" sizes="32x32" href="/calendario-impuestos/favicon-32.png?v=glm-2026" />
<link rel="icon" type="image/png" sizes="192x192" href="/calendario-impuestos/favicon-192.png?v=glm-2026" />
<link rel="shortcut icon" href="/calendario-impuestos/favicon.ico?v=glm-2026" />
<link rel="apple-touch-icon" sizes="180x180" href="/calendario-impuestos/apple-touch-icon.png?v=glm-2026" />
<script type="module" crossorigin src="/calendario-impuestos/assets/index-DjmlsRNV.js"></script>
<link rel="stylesheet" crossorigin href="/calendario-impuestos/assets/index-DrJGHNJG.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+83 -24
View File
@@ -1,37 +1,96 @@
# Configuración del flujo n8n # Configuración de n8n — Seguimiento de Impuestos GLM
Después de importar `Seguimiento-de-Impuestos-GLM.json`: Este proyecto incluye tres workflows independientes para facilitar pruebas y mantenimiento.
1. En el nodo **Gmail - Enviar recordatorio**, selecciona la credencial de Gmail que enviará los correos. ## 1. Recordatorios por Gmail y Google Chat
2. Configura estas variables de entorno en n8n:
```env Archivo:
SUPABASE_URL=https://dbit.digitalcompass.agency
SUPABASE_SERVICE_ROLE_KEY=REEMPLAZAR
WHATSAPP_BASE_URL=https://wsp.gomezleemarketing.com
WHATSAPP_INSTANCE=botsoporte
WHATSAPP_API_KEY=REEMPLAZAR_CON_LA_APIKEY
GLM_TAX_APP_URL=https://digitalcompass.agency/calendario-impuestos/
GLM_LOGO_URL=https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png
```
3. Activa el workflow. El Schedule Trigger está configurado para las 8:00 AM en `America/Santo_Domingo`. `Seguimiento-de-Impuestos-GLM-Recordatorios-Actualizado.json`
4. Para una prueba manual, usa **Ejecutar manualmente**. Para ejecutar una fecha específica por webhook:
- Se ejecuta diariamente a las 8:00 a. m. en `America/Santo_Domingo`.
- Consulta `tax_due_reminders` en Supabase.
- Envía correos HTML con identidad visual GLM.
- Envía mensajes por Google Chat.
- Registra cada envío para evitar duplicados.
Después de importar:
1. Seleccionar la credencial correcta en `Gmail - Enviar recordatorio`.
2. Confirmar la URL y API key del nodo de Google Chat.
3. Desactivar el workflow anterior antes de activar este, para evitar duplicados.
4. Ejecutar una prueba manual con una fecha controlada.
## 2. Búsqueda de contactos en BambooHR
Archivo:
`Seguimiento-de-Impuestos-GLM-BambooHR-Contactos.json`
Production URL esperada:
`https://agenteit.digitalcompass.agency/webhook/seguimiento-impuestos-bamboohr-contacto`
Después de importar:
1. Abrir `Generar reporte completo BambooHR`.
2. Confirmar la credencial `BambooHR GLM Full Access`.
3. Activar el workflow.
4. Probar con:
```json ```json
{ {
"action": "run_now", "action": "search_contact",
"run_date": "2026-07-24" "full_name": "Nombre Apellido"
} }
``` ```
Las notificaciones del frontend (`contact.created`, `obligation.created`, etc.) sólo confirman cambios y no disparan recordatorios. El workflow normaliza nombres, tolera tildes y diferencias menores, prioriza coincidencias exactas y devuelve hasta ocho candidatos con país, teléfono, área, cargo, correo y estado.
## Enrutamiento regional por categoría ## 3. Sincronización con Google Calendar
El workflow no requiere cambios para los nuevos contactos de Nómina. La RPC `tax_due_reminders` devuelve automáticamente: Archivo:
- contactos con área `regional`: todas las obligaciones; `Seguimiento-de-Impuestos-GLM-Google-Calendar.json`
- contactos del país Regional con área `nomina`: Nómina de todos los países;
- contactos del país Regional con área `admin`: Administración de todos los países; Production URL esperada:
- contactos de un país específico: únicamente la categoría de ese país.
`https://agenteit.digitalcompass.agency/webhook/seguimiento-impuestos-google-calendar`
Antes de activarlo, ejecutar en Supabase:
`../supabase/actualizacion_bamboohr_google_calendar.sql`
Después de importar:
1. Crear o seleccionar una credencial OAuth2 de Google Calendar.
2. Asignarla en estos nodos:
- `Google Calendar - Crear evento`
- `Google Calendar - Actualizar evento`
- `Google Calendar - Eliminar evento`
3. Verificar que la cuenta conectada sea la cuenta organizadora de los eventos.
4. Activar el workflow.
Cada obligación crea un evento de 8:00 a. m. a 9:00 a. m., hora de República Dominicana, e invita a los contactos aplicables. Las modificaciones y eliminaciones realizadas antes del primer aviso también se sincronizan.
## Orden recomendado de despliegue
1. Ejecutar el parche SQL.
2. Importar y activar BambooHR.
3. Importar y activar Google Calendar.
4. Reemplazar el workflow de recordatorios.
5. Desplegar el frontend actualizado.
6. Probar un contacto y una obligación futura de prueba.
## Google Chat — mensajes directos
El workflow de recordatorios usa una credencial **Google Chat OAuth2 API** con los scopes incluidos por n8n (`chat.spaces`, `chat.messages` y `chat.memberships`). Después de importar:
1. Habilita Google Chat API en el proyecto de Google Cloud.
2. Crea o selecciona una credencial `Google Chat OAuth2 API` en n8n.
3. Asígnala a `Google Chat - Abrir DM` y `Google Chat - Enviar recordatorio`.
4. Autentica con la cuenta corporativa que enviará los mensajes.
5. Ejecuta una prueba manual antes de activar el Schedule Trigger.
El flujo crea o reutiliza un mensaje directo con el correo corporativo del contacto, envía el aviso y registra el resultado en `tax_notification_log` con canal `google_chat`.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,775 @@
{
"name": "Seguimiento de Impuestos GLM - Google Calendar",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "seguimiento-impuestos-google-calendar",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
320,
480
],
"id": "1969add0-835c-459f-906e-ecb0bc43b175",
"name": "Webhook - Google Calendar",
"webhookId": "seguimiento-impuestos-google-calendar"
},
{
"parameters": {
"jsCode": "let body = $json.body ?? $json;\nif (typeof body === 'string') {\n try { body = JSON.parse(body); }\n catch { body = {}; }\n}\nconst action = String(body.action ?? '').trim().toLowerCase();\nconst payload = body.payload ?? {};\nconst obligationId = String(payload.obligation_id ?? body.obligation_id ?? '').trim();\nconst allowed = ['obligation.created', 'obligation.updated', 'obligation.deleted'];\nreturn [{\n json: {\n valid: allowed.includes(action) && Boolean(obligationId),\n action,\n obligation_id: obligationId,\n message: allowed.includes(action)\n ? (obligationId ? '' : 'Falta obligation_id.')\n : 'Acción no soportada.',\n },\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
560,
480
],
"id": "4257a973-d67c-4972-a140-0acdeaa0367e",
"name": "Preparar solicitud"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "771f008a-734d-436d-9122-448758a2263a",
"leftValue": "={{ $json.valid }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
800,
480
],
"id": "7714ac21-6eed-4eaa-aa57-1f145ace0b13",
"name": "Solicitud válida"
},
{
"parameters": {
"method": "POST",
"url": "https://dbit.digitalcompass.agency/rest/v1/rpc/tax_google_calendar_payload",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Authorization",
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { p_obligation_id: $json.obligation_id } }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
1056,
384
],
"id": "60720896-3eef-4a6f-9c8f-406bc777137b",
"name": "Supabase - Obtener obligación y asistentes"
},
{
"parameters": {
"jsCode": "const source = $('Preparar solicitud').item.json;\nconst value = $json;\nconst row = Array.isArray(value) ? value[0] : Array.isArray(value?.data) ? value.data[0] : value;\nif (!row?.obligation_id) {\n return [{ json: { ...source, operation: 'noop', reason: 'No se encontró la obligación.' } }];\n}\n\nconst attendees = Array.isArray(row.attendees)\n ? [...new Set(row.attendees.map((email) => String(email ?? '').trim().toLowerCase()).filter(Boolean))]\n : [];\nconst start = `${row.due_date}T08:00:00-04:00`;\nconst end = `${row.due_date}T09:00:00-04:00`;\nconst categoryLabel = row.category === 'nomina' ? 'Nómina' : 'Administración';\nconst summary = `Impuestos · ${row.country_name} · ${row.description}`;\nconst description = [\n 'Seguimiento de Impuestos GLM',\n '',\n `País: ${row.country_name}`,\n `Categoría: ${categoryLabel}`,\n `Fecha límite: ${row.due_date}`,\n `Obligación: ${row.description}`,\n '',\n 'Abrir aplicación: https://home.digitalcompass.agency/',\n].join('\\n');\nconst calendarBody = {\n summary,\n description,\n start: { dateTime: start, timeZone: 'America/Santo_Domingo' },\n end: { dateTime: end, timeZone: 'America/Santo_Domingo' },\n attendees: attendees.map((email) => ({ email })),\n reminders: {\n useDefault: false,\n overrides: [\n { method: 'email', minutes: 0 },\n { method: 'popup', minutes: 0 },\n ],\n },\n extendedProperties: {\n private: {\n glmTaxObligationId: row.obligation_id,\n glmTaxCountryCode: row.country_code,\n glmTaxCategory: row.category,\n },\n },\n};\n\nlet operation = 'create';\nif (source.action === 'obligation.deleted' || row.active === false) {\n operation = row.google_event_id ? 'delete' : 'noop';\n} else if (row.google_event_id) {\n operation = 'update';\n}\n\nreturn [{\n json: {\n ...source,\n ...row,\n attendees,\n start,\n end,\n summary,\n calendar_description: description,\n calendar_body: calendarBody,\n calendar_id: row.calendar_id || 'primary',\n operation,\n },\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1312,
384
],
"id": "437c0861-d237-43a9-acb6-a3cecad1c748",
"name": "Preparar payload Calendar"
},
{
"parameters": {
"jsCode": "return $input.all().filter((item) => item.json.operation === 'create');"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1568,
256
],
"id": "21075755-7ae2-4f5a-ae0e-63e5e62b9e78",
"name": "Preparar crear"
},
{
"parameters": {
"jsCode": "return $input.all().filter((item) => item.json.operation === 'update');"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1568,
416
],
"id": "b20f873b-1b6d-4fde-8832-1dae87674f60",
"name": "Preparar actualizar"
},
{
"parameters": {
"jsCode": "return $input.all().filter((item) => item.json.operation === 'delete');"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1568,
576
],
"id": "21a0f3e2-c6e6-40e8-b013-30ba4c739a44",
"name": "Preparar eliminar"
},
{
"parameters": {
"jsCode": "return $input.all().filter((item) => item.json.operation === 'noop');"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1568,
736
],
"id": "9efc968a-6ba7-41bd-8bdb-f391a6b482f2",
"name": "Preparar sin acción"
},
{
"parameters": {
"method": "POST",
"url": "https://www.googleapis.com/calendar/v3/calendars/primary/events?sendUpdates=all",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleCalendarOAuth2Api",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json.calendar_body }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
1824,
256
],
"id": "b4a40589-9002-4479-8576-7c01d746fb65",
"name": "Google Calendar - Crear evento",
"credentials": {
"googleCalendarOAuth2Api": {
"id": "REEMPLAZAR_CREDENCIAL_GOOGLE_CALENDAR",
"name": "Google Calendar GLM"
}
}
},
{
"parameters": {
"method": "PATCH",
"url": "={{ 'https://www.googleapis.com/calendar/v3/calendars/primary/events/' + encodeURIComponent($json.google_event_id) + '?sendUpdates=all' }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleCalendarOAuth2Api",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json.calendar_body }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
1824,
416
],
"id": "443971f0-bb68-4051-adbc-81f46e7cf785",
"name": "Google Calendar - Actualizar evento",
"credentials": {
"googleCalendarOAuth2Api": {
"id": "REEMPLAZAR_CREDENCIAL_GOOGLE_CALENDAR",
"name": "Google Calendar GLM"
}
}
},
{
"parameters": {
"method": "DELETE",
"url": "={{ 'https://www.googleapis.com/calendar/v3/calendars/primary/events/' + encodeURIComponent($json.google_event_id) + '?sendUpdates=all' }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleCalendarOAuth2Api",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
1824,
576
],
"id": "9658c53c-ebeb-4c28-94cd-e56011a6175e",
"name": "Google Calendar - Eliminar evento",
"credentials": {
"googleCalendarOAuth2Api": {
"id": "REEMPLAZAR_CREDENCIAL_GOOGLE_CALENDAR",
"name": "Google Calendar GLM"
}
}
},
{
"parameters": {
"jsCode": "const source = $('Preparar crear').item.json; return [{ json: { ...source, google_event_id: String($json.id ?? ''), google_response: $json, sync_action: 'create', sync_status: 'active' } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2080,
256
],
"id": "a807b37a-f61c-46de-87f8-74a436a92e00",
"name": "Preparar registro creado"
},
{
"parameters": {
"jsCode": "const source = $('Preparar actualizar').item.json; return [{ json: { ...source, google_event_id: source.google_event_id, google_response: $json, sync_action: 'update', sync_status: 'active' } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2080,
416
],
"id": "497396c0-d40e-4613-aeb4-baf52ce8f447",
"name": "Preparar registro actualizado"
},
{
"parameters": {
"jsCode": "const source = $('Preparar eliminar').item.json; return [{ json: { ...source, google_event_id: source.google_event_id, google_response: $json ?? {}, sync_action: 'delete', sync_status: 'deleted' } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2080,
576
],
"id": "034ad2a5-526e-4d4c-9a56-db9e83bde071",
"name": "Preparar registro eliminado"
},
{
"parameters": {
"method": "POST",
"url": "https://dbit.digitalcompass.agency/rest/v1/rpc/tax_record_google_calendar_sync",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Authorization",
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { p_obligation_id: $('Preparar registro creado').item.json.obligation_id, p_google_event_id: $('Preparar registro creado').item.json.google_event_id, p_calendar_id: 'primary', p_attendees: $('Preparar registro creado').item.json.attendees, p_status: $('Preparar registro creado').item.json.sync_status, p_last_action: $('Preparar registro creado').item.json.sync_action, p_provider_response: $('Preparar registro creado').item.json.google_response } }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2336,
256
],
"id": "5072ebec-03ee-49e7-b050-6cc93b538f7b",
"name": "Supabase - Registrar creación Calendar"
},
{
"parameters": {
"method": "POST",
"url": "https://dbit.digitalcompass.agency/rest/v1/rpc/tax_record_google_calendar_sync",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Authorization",
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { p_obligation_id: $('Preparar registro actualizado').item.json.obligation_id, p_google_event_id: $('Preparar registro actualizado').item.json.google_event_id, p_calendar_id: 'primary', p_attendees: $('Preparar registro actualizado').item.json.attendees, p_status: $('Preparar registro actualizado').item.json.sync_status, p_last_action: $('Preparar registro actualizado').item.json.sync_action, p_provider_response: $('Preparar registro actualizado').item.json.google_response } }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2336,
416
],
"id": "774d2dab-bb85-44e5-aeaf-e4dfe6954931",
"name": "Supabase - Registrar actualización Calendar"
},
{
"parameters": {
"method": "POST",
"url": "https://dbit.digitalcompass.agency/rest/v1/rpc/tax_record_google_calendar_sync",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Authorization",
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { p_obligation_id: $('Preparar registro eliminado').item.json.obligation_id, p_google_event_id: $('Preparar registro eliminado').item.json.google_event_id, p_calendar_id: 'primary', p_attendees: $('Preparar registro eliminado').item.json.attendees, p_status: $('Preparar registro eliminado').item.json.sync_status, p_last_action: $('Preparar registro eliminado').item.json.sync_action, p_provider_response: $('Preparar registro eliminado').item.json.google_response } }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2336,
576
],
"id": "67986a91-dbb4-4ab2-b33e-e003ac8895df",
"name": "Supabase - Registrar eliminación Calendar"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { ok: true, message: 'Evento creado en Google Calendar.' } }}",
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "Access-Control-Allow-Origin",
"value": "*"
},
{
"name": "Cache-Control",
"value": "no-store"
}
]
}
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.4,
"position": [
2592,
256
],
"id": "932c9da0-2a24-4eb3-ac40-3ccaf5b1895a",
"name": "Responder creación"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { ok: true, message: 'Evento actualizado en Google Calendar.' } }}",
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "Access-Control-Allow-Origin",
"value": "*"
},
{
"name": "Cache-Control",
"value": "no-store"
}
]
}
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.4,
"position": [
2592,
416
],
"id": "bbeb54b6-30c9-43f8-9c54-7564a1684d3b",
"name": "Responder actualización"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { ok: true, message: 'Evento eliminado de Google Calendar.' } }}",
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "Access-Control-Allow-Origin",
"value": "*"
},
{
"name": "Cache-Control",
"value": "no-store"
}
]
}
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.4,
"position": [
2592,
576
],
"id": "51bee51d-e61a-473a-a200-a447b295a59e",
"name": "Responder eliminación"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { ok: true, message: $json.reason || 'No había cambios que sincronizar.' } }}",
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "Access-Control-Allow-Origin",
"value": "*"
}
]
}
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.4,
"position": [
1824,
736
],
"id": "f3b38547-3de2-4499-94c8-7a331cae5c9a",
"name": "Responder sin acción"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { ok: false, message: $json.message } }}",
"options": {
"responseCode": 400,
"responseHeaders": {
"entries": [
{
"name": "Access-Control-Allow-Origin",
"value": "*"
}
]
}
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.4,
"position": [
1056,
656
],
"id": "558b4170-3ae9-471d-a506-5d404ccde75e",
"name": "Responder solicitud inválida"
}
],
"pinData": {},
"connections": {
"Webhook - Google Calendar": {
"main": [
[
{
"node": "Preparar solicitud",
"type": "main",
"index": 0
}
]
]
},
"Preparar solicitud": {
"main": [
[
{
"node": "Solicitud válida",
"type": "main",
"index": 0
}
]
]
},
"Solicitud válida": {
"main": [
[
{
"node": "Supabase - Obtener obligación y asistentes",
"type": "main",
"index": 0
}
],
[
{
"node": "Responder solicitud inválida",
"type": "main",
"index": 0
}
]
]
},
"Supabase - Obtener obligación y asistentes": {
"main": [
[
{
"node": "Preparar payload Calendar",
"type": "main",
"index": 0
}
]
]
},
"Preparar payload Calendar": {
"main": [
[
{
"node": "Preparar crear",
"type": "main",
"index": 0
},
{
"node": "Preparar actualizar",
"type": "main",
"index": 0
},
{
"node": "Preparar eliminar",
"type": "main",
"index": 0
},
{
"node": "Preparar sin acción",
"type": "main",
"index": 0
}
]
]
},
"Preparar crear": {
"main": [
[
{
"node": "Google Calendar - Crear evento",
"type": "main",
"index": 0
}
]
]
},
"Google Calendar - Crear evento": {
"main": [
[
{
"node": "Preparar registro creado",
"type": "main",
"index": 0
}
]
]
},
"Preparar registro creado": {
"main": [
[
{
"node": "Supabase - Registrar creación Calendar",
"type": "main",
"index": 0
}
]
]
},
"Supabase - Registrar creación Calendar": {
"main": [
[
{
"node": "Responder creación",
"type": "main",
"index": 0
}
]
]
},
"Preparar actualizar": {
"main": [
[
{
"node": "Google Calendar - Actualizar evento",
"type": "main",
"index": 0
}
]
]
},
"Google Calendar - Actualizar evento": {
"main": [
[
{
"node": "Preparar registro actualizado",
"type": "main",
"index": 0
}
]
]
},
"Preparar registro actualizado": {
"main": [
[
{
"node": "Supabase - Registrar actualización Calendar",
"type": "main",
"index": 0
}
]
]
},
"Supabase - Registrar actualización Calendar": {
"main": [
[
{
"node": "Responder actualización",
"type": "main",
"index": 0
}
]
]
},
"Preparar eliminar": {
"main": [
[
{
"node": "Google Calendar - Eliminar evento",
"type": "main",
"index": 0
}
]
]
},
"Google Calendar - Eliminar evento": {
"main": [
[
{
"node": "Preparar registro eliminado",
"type": "main",
"index": 0
}
]
]
},
"Preparar registro eliminado": {
"main": [
[
{
"node": "Supabase - Registrar eliminación Calendar",
"type": "main",
"index": 0
}
]
]
},
"Supabase - Registrar eliminación Calendar": {
"main": [
[
{
"node": "Responder eliminación",
"type": "main",
"index": 0
}
]
]
},
"Preparar sin acción": {
"main": [
[
{
"node": "Responder sin acción",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"timezone": "America/Santo_Domingo",
"callerPolicy": "workflowsFromSameOwner"
},
"meta": {
"templateCredsSetupCompleted": true
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+649 -111
View File
File diff suppressed because it is too large Load Diff
+210 -69
View File
@@ -1,5 +1,12 @@
import type { SupabaseAuthSession } from "./supabase-auth"; import type { SupabaseAuthSession } from "./supabase-auth";
import type { Contact, ContactArea, Country, TaxCategory, TaxEvent } from "./tax-utils"; import type {
BambooContactCandidate,
Contact,
ContactArea,
Country,
TaxCategory,
TaxEvent,
} from "./tax-utils";
const supabaseUrl = String(import.meta.env.VITE_SUPABASE_URL ?? "") const supabaseUrl = String(import.meta.env.VITE_SUPABASE_URL ?? "")
.trim() .trim()
@@ -7,7 +14,12 @@ const supabaseUrl = String(import.meta.env.VITE_SUPABASE_URL ?? "")
const publishableKey = String(import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY ?? "").trim(); const publishableKey = String(import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY ?? "").trim();
const anonKey = String(import.meta.env.VITE_SUPABASE_ANON_KEY ?? "").trim(); const anonKey = String(import.meta.env.VITE_SUPABASE_ANON_KEY ?? "").trim();
const supabaseKey = publishableKey || anonKey; const supabaseKey = publishableKey || anonKey;
const workflowUrl = String(import.meta.env.VITE_TAX_WEBHOOK_URL ?? "").trim(); const bambooContactWorkflowUrl = String(
import.meta.env.VITE_BAMBOOHR_CONTACT_WEBHOOK_URL ?? "",
).trim();
const googleCalendarWorkflowUrl = String(
import.meta.env.VITE_GOOGLE_CALENDAR_WEBHOOK_URL ?? "",
).trim();
type CountryRow = { type CountryRow = {
id: string; id: string;
@@ -45,11 +57,18 @@ type ContactRow = {
country_id: string; country_id: string;
full_name: string; full_name: string;
email: string | null; email: string | null;
whatsapp_number: string | null;
job_title: string | null; job_title: string | null;
area: ContactArea; area: ContactArea;
department: string | null;
division: string | null;
location: string | null;
source: string | null;
bamboo_employee_id: string | null;
bamboo_employee_number: string | null;
bamboo_status: string | null;
email_enabled: boolean; email_enabled: boolean;
whatsapp_enabled: boolean; google_chat_enabled: boolean;
calendar_enabled: boolean;
}; };
export type NewObligation = { export type NewObligation = {
@@ -63,11 +82,24 @@ export type NewContact = {
countryId: string; countryId: string;
name: string; name: string;
email: string; email: string;
phone: string;
role: string; role: string;
area: ContactArea; area: ContactArea;
department?: string;
division?: string;
location?: string;
source?: "manual" | "bamboohr" | string;
bambooEmployeeId?: string;
bambooEmployeeNumber?: string;
bambooStatus?: string;
emailEnabled: boolean; emailEnabled: boolean;
whatsappEnabled: boolean; googleChatEnabled: boolean;
calendarEnabled: boolean;
};
type BambooSearchResponse = {
ok?: boolean;
message?: string;
candidates?: BambooContactCandidate[];
}; };
export async function loadObligationDeliveryStatuses( export async function loadObligationDeliveryStatuses(
@@ -102,7 +134,7 @@ export async function loadTaxData(session: SupabaseAuthSession): Promise<{
), ),
request<ContactRow[]>( request<ContactRow[]>(
session, session,
"/rest/v1/tax_contacts?select=id,country_id,full_name,email,whatsapp_number,job_title,area,email_enabled,whatsapp_enabled&active=eq.true&order=full_name.asc", "/rest/v1/tax_contacts?select=id,country_id,full_name,email,job_title,area,department,division,location,source,bamboo_employee_id,bamboo_employee_number,bamboo_status,email_enabled,google_chat_enabled,calendar_enabled&active=eq.true&order=full_name.asc",
), ),
loadObligationDeliveryStatuses(session), loadObligationDeliveryStatuses(session),
]); ]);
@@ -128,23 +160,7 @@ export async function loadTaxData(session: SupabaseAuthSession): Promise<{
} satisfies TaxEvent; } satisfies TaxEvent;
}); });
const contacts = contactRows.map((row) => { const contacts = contactRows.map((row) => mapContactRow(row, countryMap));
const country = countryMap.get(row.country_id);
if (!country) throw new Error("Un contacto tiene un país inválido.");
return {
id: row.id,
countryId: row.country_id,
country: country.name,
countryCode: country.code,
name: row.full_name,
email: row.email ?? "",
phone: row.whatsapp_number ?? "",
role: row.job_title ?? "",
area: row.area,
emailEnabled: row.email_enabled,
whatsappEnabled: row.whatsapp_enabled,
} satisfies Contact;
});
return { countries, events, contacts }; return { countries, events, contacts };
} }
@@ -153,6 +169,8 @@ export async function createObligation(
session: SupabaseAuthSession, session: SupabaseAuthSession,
input: NewObligation, input: NewObligation,
): Promise<TaxEvent> { ): Promise<TaxEvent> {
assertNotPastDate(input.date);
const [row] = await request<ObligationRow[]>(session, "/rest/v1/tax_obligations", { const [row] = await request<ObligationRow[]>(session, "/rest/v1/tax_obligations", {
method: "POST", method: "POST",
headers: { Prefer: "return=representation" }, headers: { Prefer: "return=representation" },
@@ -170,10 +188,11 @@ export async function createObligation(
const country = countries.find((item) => item.id === row.country_id); const country = countries.find((item) => item.id === row.country_id);
if (!country) throw new Error("No se encontró el país de la obligación."); if (!country) throw new Error("No se encontró el país de la obligación.");
void notifyWorkflow("obligation.created", { const payload = {
obligation_id: row.id, obligation_id: row.id,
due_date: row.due_date, due_date: row.due_date,
}); };
void notifyGoogleCalendar("obligation.created", payload);
return { return {
id: row.id, id: row.id,
@@ -195,6 +214,8 @@ export async function updateObligation(
id: string, id: string,
input: NewObligation, input: NewObligation,
): Promise<TaxEvent> { ): Promise<TaxEvent> {
assertNotPastDate(input.date);
const [row] = await request<ObligationRow[]>( const [row] = await request<ObligationRow[]>(
session, session,
`/rest/v1/tax_obligations?id=eq.${encodeURIComponent(id)}`, `/rest/v1/tax_obligations?id=eq.${encodeURIComponent(id)}`,
@@ -216,10 +237,11 @@ export async function updateObligation(
const country = countries.find((item) => item.id === row.country_id); const country = countries.find((item) => item.id === row.country_id);
if (!country) throw new Error("No se encontró el país de la obligación."); if (!country) throw new Error("No se encontró el país de la obligación.");
void notifyWorkflow("obligation.updated", { const payload = {
obligation_id: row.id, obligation_id: row.id,
due_date: row.due_date, due_date: row.due_date,
}); };
void notifyGoogleCalendar("obligation.updated", payload);
return { return {
id: row.id, id: row.id,
@@ -242,7 +264,8 @@ export async function removeObligation(session: SupabaseAuthSession, id: string)
headers: { Prefer: "return=minimal" }, headers: { Prefer: "return=minimal" },
body: JSON.stringify({ active: false }), body: JSON.stringify({ active: false }),
}); });
void notifyWorkflow("obligation.deleted", { obligation_id: id }); const payload = { obligation_id: id };
void notifyGoogleCalendar("obligation.deleted", payload);
} }
export async function createContact( export async function createContact(
@@ -252,40 +275,38 @@ export async function createContact(
const [row] = await request<ContactRow[]>(session, "/rest/v1/tax_contacts", { const [row] = await request<ContactRow[]>(session, "/rest/v1/tax_contacts", {
method: "POST", method: "POST",
headers: { Prefer: "return=representation" }, headers: { Prefer: "return=representation" },
body: JSON.stringify({ body: JSON.stringify(contactPayload(input)),
country_id: input.countryId,
full_name: input.name.trim(),
email: input.email.trim() || null,
whatsapp_number: normalizeWhatsApp(input.phone) || null,
job_title: input.role.trim() || null,
area: input.area,
email_enabled: input.emailEnabled,
whatsapp_enabled: input.whatsappEnabled,
}),
}); });
const countries = await getCountries(session); const countries = await getCountries(session);
const country = countries.find((item) => item.id === row.country_id); const countryMap = new Map(countries.map((country) => [country.id, country]));
if (!country) throw new Error("No se encontró el país del contacto."); const created = mapContactRow(row, countryMap);
void notifyWorkflow("contact.created", { return created;
contact_id: row.id, }
country_id: row.country_id,
});
return { export async function updateContact(
id: row.id, session: SupabaseAuthSession,
countryId: row.country_id, id: string,
country: country.name, input: NewContact,
countryCode: country.code, ): Promise<Contact> {
name: row.full_name, const [row] = await request<ContactRow[]>(
email: row.email ?? "", session,
phone: row.whatsapp_number ?? "", `/rest/v1/tax_contacts?id=eq.${encodeURIComponent(id)}`,
role: row.job_title ?? "", {
area: row.area, method: "PATCH",
emailEnabled: row.email_enabled, headers: { Prefer: "return=representation" },
whatsappEnabled: row.whatsapp_enabled, body: JSON.stringify(contactPayload(input)),
}; },
);
if (!row) throw new Error("No se encontró el contacto.");
const countries = await getCountries(session);
const countryMap = new Map(countries.map((country) => [country.id, country]));
const updated = mapContactRow(row, countryMap);
return updated;
} }
export async function removeContact(session: SupabaseAuthSession, id: string): Promise<void> { export async function removeContact(session: SupabaseAuthSession, id: string): Promise<void> {
@@ -294,7 +315,50 @@ export async function removeContact(session: SupabaseAuthSession, id: string): P
headers: { Prefer: "return=minimal" }, headers: { Prefer: "return=minimal" },
body: JSON.stringify({ active: false }), body: JSON.stringify({ active: false }),
}); });
void notifyWorkflow("contact.deleted", { contact_id: id }); }
export async function searchBambooContacts(
session: SupabaseAuthSession,
fullName: string,
): Promise<BambooContactCandidate[]> {
if (!bambooContactWorkflowUrl) {
throw new Error("Falta configurar el webhook de BambooHR.");
}
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 300_000);
try {
const response = await fetch(bambooContactWorkflowUrl, {
method: "POST",
headers: { "Content-Type": "text/plain;charset=UTF-8" },
body: JSON.stringify({
action: "search_contact",
full_name: fullName.trim(),
requested_by: session.user.email,
source: "seguimiento-impuestos-glm",
}),
signal: controller.signal,
});
if (!response.ok) {
throw new Error("No pudimos consultar BambooHR.");
}
const payload = (await response.json()) as BambooSearchResponse;
if (payload.ok === false) {
throw new Error(payload.message || "No pudimos consultar BambooHR.");
}
return Array.isArray(payload.candidates) ? payload.candidates : [];
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error("La búsqueda tardó demasiado. Inténtalo nuevamente.");
}
throw error;
} finally {
window.clearTimeout(timeout);
}
} }
async function getCountries(session: SupabaseAuthSession): Promise<Country[]> { async function getCountries(session: SupabaseAuthSession): Promise<Country[]> {
@@ -335,6 +399,86 @@ async function request<T = unknown>(
return (text ? JSON.parse(text) : undefined) as T; return (text ? JSON.parse(text) : undefined) as T;
} }
function mapContactRow(row: ContactRow, countryMap: Map<string, Country>): Contact {
const country = countryMap.get(row.country_id);
if (!country) throw new Error("Un contacto tiene un país inválido.");
return {
id: row.id,
countryId: row.country_id,
country: country.name,
countryCode: country.code,
name: row.full_name,
email: row.email ?? "",
role: row.job_title ?? "",
area: row.area,
department: row.department ?? "",
division: row.division ?? "",
location: row.location ?? "",
source: row.source ?? "manual",
bambooEmployeeId: row.bamboo_employee_id ?? "",
bambooEmployeeNumber: row.bamboo_employee_number ?? "",
bambooStatus: row.bamboo_status ?? "",
emailEnabled: row.email_enabled,
googleChatEnabled: row.google_chat_enabled,
calendarEnabled: row.calendar_enabled,
};
}
function contactPayload(input: NewContact) {
return {
country_id: input.countryId,
full_name: input.name.trim(),
email: input.email.trim() || null,
job_title: input.role.trim() || null,
area: input.area,
department: input.department?.trim() || null,
division: input.division?.trim() || null,
location: input.location?.trim() || null,
source: input.source || "manual",
bamboo_employee_id: input.bambooEmployeeId?.trim() || null,
bamboo_employee_number: input.bambooEmployeeNumber?.trim() || null,
bamboo_status: input.bambooStatus?.trim() || null,
email_enabled: input.emailEnabled,
google_chat_enabled: input.googleChatEnabled,
calendar_enabled: input.calendarEnabled,
};
}
function assertNotPastDate(value: string): void {
const date = String(value ?? "").trim();
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
if (!match) {
throw new Error("Selecciona una fecha válida.");
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const parsed = new Date(Date.UTC(year, month - 1, day));
if (
parsed.getUTCFullYear() !== year ||
parsed.getUTCMonth() !== month - 1 ||
parsed.getUTCDate() !== day
) {
throw new Error("Selecciona una fecha válida.");
}
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "America/Santo_Domingo",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
const today = `${values.year}-${values.month}-${values.day}`;
if (date < today) {
throw new Error("No puedes crear ni mover una obligación a una fecha que ya pasó.");
}
}
async function readError(response: Response): Promise<string> { async function readError(response: Response): Promise<string> {
try { try {
const payload = (await response.json()) as { message?: string; details?: string }; const payload = (await response.json()) as { message?: string; details?: string };
@@ -344,23 +488,20 @@ async function readError(response: Response): Promise<string> {
} }
} }
function normalizeWhatsApp(value: string): string { async function notifyGoogleCalendar(action: string, payload: Record<string, unknown>) {
const trimmed = value.trim(); await postWebhook(googleCalendarWorkflowUrl, action, payload);
if (!trimmed) return "";
if (trimmed.includes("@g.us") || trimmed.includes("@s.whatsapp.net")) return trimmed;
return trimmed.replace(/\D/g, "");
} }
async function notifyWorkflow(action: string, payload: Record<string, unknown>) { async function postWebhook(url: string, action: string, payload: Record<string, unknown>) {
if (!workflowUrl) return; if (!url) return;
try { try {
await fetch(workflowUrl, { await fetch(url, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "text/plain;charset=UTF-8" },
body: JSON.stringify({ action, payload, source: "frontend" }), body: JSON.stringify({ action, payload, source: "frontend" }),
keepalive: true, keepalive: true,
}); });
} catch { } catch {
// La base de datos ya guardó el cambio. El webhook es sólo una notificación. // La base de datos ya guardó el cambio. El webhook es una sincronización secundaria.
} }
} }
+28 -3
View File
@@ -22,7 +22,7 @@ export type TaxEvent = {
sentNotificationCount: number; sentNotificationCount: number;
}; };
export type ContactArea = "admin" | "nomina" | "regional"; export type ContactArea = "admin" | "nomina" | "regional" | "other";
export type Contact = { export type Contact = {
id: string; id: string;
@@ -31,11 +31,36 @@ export type Contact = {
countryCode: string; countryCode: string;
name: string; name: string;
email: string; email: string;
phone: string;
role: string; role: string;
area: ContactArea; area: ContactArea;
department: string;
division: string;
location: string;
source: "manual" | "bamboohr" | string;
bambooEmployeeId: string;
bambooEmployeeNumber: string;
bambooStatus: string;
emailEnabled: boolean; emailEnabled: boolean;
whatsappEnabled: boolean; googleChatEnabled: boolean;
calendarEnabled: boolean;
};
export type BambooContactCandidate = {
id: string;
employeeNumber: string;
fullName: string;
displayName: string;
email: string;
jobTitle: string;
department: string;
division: string;
location: string;
countryCode: string;
countryName: string;
area: ContactArea;
status: string;
score: number;
matchQuality: "exacta" | "alta" | "media";
}; };
// Siempre se alerta el miércoles anterior (estrictamente anterior) y la fecha // Siempre se alerta el miércoles anterior (estrictamente anterior) y la fecha
+2
View File
@@ -5,6 +5,8 @@ interface ImportMetaEnv {
readonly VITE_SUPABASE_PUBLISHABLE_KEY?: string; readonly VITE_SUPABASE_PUBLISHABLE_KEY?: string;
readonly VITE_SUPABASE_ANON_KEY?: string; readonly VITE_SUPABASE_ANON_KEY?: string;
readonly VITE_TAX_WEBHOOK_URL?: string; readonly VITE_TAX_WEBHOOK_URL?: string;
readonly VITE_BAMBOOHR_CONTACT_WEBHOOK_URL?: string;
readonly VITE_GOOGLE_CALENDAR_WEBHOOK_URL?: string;
} }
interface ImportMeta { interface ImportMeta {
+14 -34
View File
@@ -1,24 +1,24 @@
# Configuración de Supabase # Configuración de Supabase
Ejecuta una sola vez el script completo: ## Base existente
La base ya recibió `actualizacion_bamboohr_google_calendar.sql`. Ejecuta ahora una sola vez:
```text
actualizacion_google_chat_sin_whatsapp.sql
```
Este parche agrega Google Chat, desactiva WhatsApp sin borrar datos históricos y actualiza la RPC de recordatorios.
## Instalación nueva
Ejecuta:
```text ```text
Seguimiento-de-Impuestos-GLM.sql Seguimiento-de-Impuestos-GLM.sql
``` ```
El script crea el control de acceso, países, obligaciones, contactos, bitácora de envíos, RLS, funciones para n8n, bloqueo de eventos notificados y los datos históricos de 2026. ## Accesos
## Si la base ya estaba creada
Ejecuta solamente esta actualización:
```text
actualizacion_eventos_bloqueados.sql
```
Esta función permite que el frontend consulte si un evento ya generó avisos y agrega una protección en PostgreSQL para impedir su modificación o eliminación después del primer envío.
## Añadir o reactivar un usuario
```sql ```sql
insert into public.tax_calendar_access (email, full_name) insert into public.tax_calendar_access (email, full_name)
@@ -28,23 +28,3 @@ set active = true,
full_name = excluded.full_name, full_name = excluded.full_name,
updated_at = now(); updated_at = now();
``` ```
## Desactivar un usuario
```sql
update public.tax_calendar_access
set active = false
where email = 'persona@gomezleemarketing.com';
```
La aplicación consulta la función segura `public.has_tax_calendar_access()`. La tabla de accesos no se expone directamente al navegador.
## Actualización final
Para una base ya instalada, ejecutar una sola vez:
```text
actualizacion_final_calendario_y_nomina.sql
```
Este parche agrega los dos contactos regionales de Nómina y actualiza `tax_due_reminders` para que un contacto ubicado en **Regional** con área `nomina` reciba Nómina de todos los países.
+448
View File
@@ -930,3 +930,451 @@ commit;
-- Prueba de recordatorios de una fecha: -- Prueba de recordatorios de una fecha:
-- select * from public.tax_due_reminders('2026-07-24'::date); -- select * from public.tax_due_reminders('2026-07-24'::date);
-- ===========================================================================
-- ACTUALIZACIÓN JULIO 2026: BambooHR, Google Calendar y fechas futuras
-- ===========================================================================
-- Seguimiento de Impuestos GLM
-- Actualización: contactos BambooHR, edición ampliada, Google Calendar y bloqueo de fechas pasadas.
-- Ejecutar una sola vez en Supabase SQL Editor antes de desplegar el frontend y activar los workflows.
begin;
-- ---------------------------------------------------------------------------
-- Contactos: metadatos de BambooHR y tercer canal de notificación
-- ---------------------------------------------------------------------------
alter table public.tax_contacts
add column if not exists department text,
add column if not exists division text,
add column if not exists location text,
add column if not exists source text not null default 'manual',
add column if not exists bamboo_employee_id text,
add column if not exists bamboo_employee_number text,
add column if not exists bamboo_status text,
add column if not exists bamboo_synced_at timestamptz,
add column if not exists calendar_enabled boolean not null default true;
alter table public.tax_contacts
drop constraint if exists tax_contacts_area_check;
alter table public.tax_contacts
add constraint tax_contacts_area_check
check (area in ('admin', 'nomina', 'regional', 'other'));
update public.tax_contacts
set calendar_enabled = false
where email is null or length(trim(email)) <= 3;
alter table public.tax_contacts
drop constraint if exists tax_contacts_calendar_email_when_enabled;
alter table public.tax_contacts
add constraint tax_contacts_calendar_email_when_enabled
check (not calendar_enabled or length(trim(coalesce(email, ''))) > 3);
create unique index if not exists tax_contacts_unique_active_bamboo_employee
on public.tax_contacts (bamboo_employee_id)
where active = true and bamboo_employee_id is not null;
create index if not exists tax_contacts_bamboo_employee_number_idx
on public.tax_contacts (bamboo_employee_number)
where bamboo_employee_number is not null;
-- Amplía la normalización existente para los nuevos campos.
create or replace function public.tax_normalize_contact()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
new.full_name := trim(new.full_name);
new.email := nullif(lower(trim(coalesce(new.email, ''))), '');
new.whatsapp_number := nullif(regexp_replace(coalesce(new.whatsapp_number, ''), '[^0-9]', '', 'g'), '');
new.job_title := nullif(trim(coalesce(new.job_title, '')), '');
new.department := nullif(trim(coalesce(new.department, '')), '');
new.division := nullif(trim(coalesce(new.division, '')), '');
new.location := nullif(trim(coalesce(new.location, '')), '');
new.source := coalesce(nullif(lower(trim(coalesce(new.source, ''))), ''), 'manual');
new.bamboo_employee_id := nullif(trim(coalesce(new.bamboo_employee_id, '')), '');
new.bamboo_employee_number := nullif(trim(coalesce(new.bamboo_employee_number, '')), '');
new.bamboo_status := nullif(trim(coalesce(new.bamboo_status, '')), '');
if new.source = 'bamboohr' then
if tg_op = 'INSERT' then
new.bamboo_synced_at := now();
elsif new.bamboo_employee_id is distinct from old.bamboo_employee_id
or new.full_name is distinct from old.full_name
or new.email is distinct from old.email
or new.whatsapp_number is distinct from old.whatsapp_number then
new.bamboo_synced_at := now();
end if;
end if;
return new;
end;
$$;
-- ---------------------------------------------------------------------------
-- No permitir obligaciones nuevas o movidas a fechas pasadas
-- ---------------------------------------------------------------------------
create or replace function public.tax_validate_future_obligation()
returns trigger
language plpgsql
set search_path = ''
as $$
declare
local_today date := (now() at time zone 'America/Santo_Domingo')::date;
begin
if new.active = true
and new.is_historical = false
and new.due_date < local_today then
if tg_op = 'INSERT' then
raise exception 'No se puede crear una obligación en una fecha que ya pasó.'
using errcode = '22007';
elsif new.due_date is distinct from old.due_date then
raise exception 'No se puede mover una obligación a una fecha que ya pasó.'
using errcode = '22007';
end if;
end if;
return new;
end;
$$;
drop trigger if exists tax_validate_future_obligation_trigger on public.tax_obligations;
create trigger tax_validate_future_obligation_trigger
before insert or update on public.tax_obligations
for each row execute function public.tax_validate_future_obligation();
-- ---------------------------------------------------------------------------
-- Sincronización con Google Calendar
-- Un evento de Google Calendar por obligación, con todos los destinatarios
-- aplicables como invitados.
-- ---------------------------------------------------------------------------
create table if not exists public.tax_google_calendar_events (
obligation_id uuid primary key references public.tax_obligations(id) on delete cascade,
google_event_id text,
calendar_id text not null default 'primary',
attendees jsonb not null default '[]'::jsonb,
status text not null default 'active'
check (status in ('active', 'deleted', 'failed')),
last_action text,
provider_response jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
synced_at timestamptz
);
alter table public.tax_google_calendar_events enable row level security;
revoke all on table public.tax_google_calendar_events from anon, authenticated;
grant select, insert, update, delete on table public.tax_google_calendar_events to service_role;
create or replace function public.tax_google_calendar_payload(p_obligation_id uuid)
returns table (
obligation_id uuid,
active boolean,
due_date date,
description text,
category text,
country_id uuid,
country_name text,
country_code text,
attendees jsonb,
google_event_id text,
calendar_id text,
sync_status text
)
language sql
stable
security definer
set search_path = ''
as $$
select
obligation.id,
obligation.active,
obligation.due_date,
obligation.description,
obligation.category,
obligation.country_id,
country.name,
country.code,
coalesce(
(
select jsonb_agg(recipient.email order by recipient.email)
from (
select distinct lower(contact.email) as email
from public.tax_contacts contact
join public.tax_countries contact_country
on contact_country.id = contact.country_id
where contact.active = true
and contact.calendar_enabled = true
and contact.email is not null
and length(trim(contact.email)) > 3
and (
contact.area = 'regional'
or (
contact_country.is_regional = true
and contact.area = obligation.category
)
or (
contact.country_id = obligation.country_id
and contact.area = obligation.category
)
)
) recipient
),
'[]'::jsonb
) as attendees,
sync.google_event_id,
coalesce(sync.calendar_id, 'primary') as calendar_id,
sync.status
from public.tax_obligations obligation
join public.tax_countries country on country.id = obligation.country_id
left join public.tax_google_calendar_events sync
on sync.obligation_id = obligation.id
where obligation.id = p_obligation_id;
$$;
revoke all on function public.tax_google_calendar_payload(uuid)
from public, anon, authenticated;
grant execute on function public.tax_google_calendar_payload(uuid)
to service_role;
create or replace function public.tax_record_google_calendar_sync(
p_obligation_id uuid,
p_google_event_id text,
p_calendar_id text default 'primary',
p_attendees jsonb default '[]'::jsonb,
p_status text default 'active',
p_last_action text default null,
p_provider_response jsonb default null
)
returns void
language plpgsql
security definer
set search_path = ''
as $$
begin
insert into public.tax_google_calendar_events (
obligation_id,
google_event_id,
calendar_id,
attendees,
status,
last_action,
provider_response,
synced_at,
updated_at
)
values (
p_obligation_id,
nullif(trim(coalesce(p_google_event_id, '')), ''),
coalesce(nullif(trim(coalesce(p_calendar_id, '')), ''), 'primary'),
coalesce(p_attendees, '[]'::jsonb),
p_status,
p_last_action,
p_provider_response,
now(),
now()
)
on conflict (obligation_id)
do update set
google_event_id = coalesce(excluded.google_event_id, public.tax_google_calendar_events.google_event_id),
calendar_id = excluded.calendar_id,
attendees = excluded.attendees,
status = excluded.status,
last_action = excluded.last_action,
provider_response = excluded.provider_response,
synced_at = now(),
updated_at = now();
end;
$$;
revoke all on function public.tax_record_google_calendar_sync(uuid, text, text, jsonb, text, text, jsonb)
from public, anon, authenticated;
grant execute on function public.tax_record_google_calendar_sync(uuid, text, text, jsonb, text, text, jsonb)
to service_role;
commit;
-- Verificaciones opcionales:
-- select * from public.tax_google_calendar_payload('UUID_DE_OBLIGACION');
-- select * from public.tax_google_calendar_events order by updated_at desc;
-- Seguimiento de Impuestos GLM
-- Actualización incremental: retirar WhatsApp y habilitar Google Chat.
-- Ejecutar después de actualizacion_bamboohr_google_calendar.sql.
begin;
alter table public.tax_contacts
add column if not exists google_chat_enabled boolean not null default true;
alter table public.tax_contacts
alter column whatsapp_enabled set default false;
-- La nueva operación deja de usar WhatsApp. Se conserva la información histórica
-- para no destruir datos ni bitácoras anteriores.
update public.tax_contacts
set
whatsapp_enabled = false,
google_chat_enabled = case
when email_enabled = true and email is not null and length(trim(email)) > 3 then true
else false
end;
alter table public.tax_contacts
drop constraint if exists tax_contacts_google_chat_email_when_enabled;
alter table public.tax_contacts
add constraint tax_contacts_google_chat_email_when_enabled
check (not google_chat_enabled or length(trim(coalesce(email, ''))) > 3);
alter table public.tax_notification_log
drop constraint if exists tax_notification_log_channel_check;
alter table public.tax_notification_log
add constraint tax_notification_log_channel_check
check (channel in ('email', 'google_chat', 'whatsapp'));
-- CREATE OR REPLACE no permite cambiar las columnas retornadas, por eso se elimina
-- y se recrea la RPC usada por n8n.
drop function if exists public.tax_due_reminders(date);
create function public.tax_due_reminders(p_run_date date default current_date)
returns table (
obligation_id uuid,
contact_id uuid,
alert_date date,
alert_type text,
due_date date,
description text,
category text,
country_name text,
country_code text,
contact_name text,
contact_email text,
email_enabled boolean,
google_chat_enabled boolean
)
language sql
stable
security definer
set search_path = ''
as $$
with due as (
select
obligation.id,
obligation.due_date,
obligation.description,
obligation.category,
obligation.country_id,
country.name as country_name,
country.code as country_code,
case
when p_run_date = obligation.due_date then 'same_day'
else 'previous_wednesday'
end as alert_type
from public.tax_obligations obligation
join public.tax_countries country on country.id = obligation.country_id
where obligation.active = true
and (
p_run_date = obligation.due_date
or p_run_date = public.tax_previous_wednesday(obligation.due_date)
)
)
select
due.id as obligation_id,
contact.id as contact_id,
p_run_date as alert_date,
due.alert_type,
due.due_date,
due.description,
due.category,
due.country_name,
due.country_code,
contact.full_name as contact_name,
contact.email as contact_email,
(
contact.email_enabled
and contact.email is not null
and not exists (
select 1
from public.tax_notification_log log
where log.obligation_id = due.id
and log.contact_id = contact.id
and log.alert_date = p_run_date
and log.channel = 'email'
and log.status = 'sent'
)
) as email_enabled,
(
contact.google_chat_enabled
and contact.email is not null
and not exists (
select 1
from public.tax_notification_log log
where log.obligation_id = due.id
and log.contact_id = contact.id
and log.alert_date = p_run_date
and log.channel = 'google_chat'
and log.status = 'sent'
)
) as google_chat_enabled
from due
join public.tax_contacts contact on contact.active = true
join public.tax_countries contact_country
on contact_country.id = contact.country_id
and (
contact.area = 'regional'
or (
contact_country.is_regional = true
and contact.area = due.category
)
or (
contact.country_id = due.country_id
and contact.area = due.category
)
)
where
(
contact.email_enabled
and contact.email is not null
and not exists (
select 1
from public.tax_notification_log log
where log.obligation_id = due.id
and log.contact_id = contact.id
and log.alert_date = p_run_date
and log.channel = 'email'
and log.status = 'sent'
)
)
or
(
contact.google_chat_enabled
and contact.email is not null
and not exists (
select 1
from public.tax_notification_log log
where log.obligation_id = due.id
and log.contact_id = contact.id
and log.alert_date = p_run_date
and log.channel = 'google_chat'
and log.status = 'sent'
)
)
order by due.due_date, due.country_name, contact.full_name;
$$;
revoke all on function public.tax_due_reminders(date) from public, anon, authenticated;
grant execute on function public.tax_due_reminders(date) to service_role;
commit;
-- Verificación opcional:
-- select full_name, email_enabled, google_chat_enabled, calendar_enabled, whatsapp_enabled
-- from public.tax_contacts where active = true order by full_name;
@@ -0,0 +1,266 @@
-- Seguimiento de Impuestos GLM
-- Actualización: contactos BambooHR, edición ampliada, Google Calendar y bloqueo de fechas pasadas.
-- Ejecutar una sola vez en Supabase SQL Editor antes de desplegar el frontend y activar los workflows.
begin;
-- ---------------------------------------------------------------------------
-- Contactos: metadatos de BambooHR y tercer canal de notificación
-- ---------------------------------------------------------------------------
alter table public.tax_contacts
add column if not exists department text,
add column if not exists division text,
add column if not exists location text,
add column if not exists source text not null default 'manual',
add column if not exists bamboo_employee_id text,
add column if not exists bamboo_employee_number text,
add column if not exists bamboo_status text,
add column if not exists bamboo_synced_at timestamptz,
add column if not exists calendar_enabled boolean not null default true;
alter table public.tax_contacts
drop constraint if exists tax_contacts_area_check;
alter table public.tax_contacts
add constraint tax_contacts_area_check
check (area in ('admin', 'nomina', 'regional', 'other'));
update public.tax_contacts
set calendar_enabled = false
where email is null or length(trim(email)) <= 3;
alter table public.tax_contacts
drop constraint if exists tax_contacts_calendar_email_when_enabled;
alter table public.tax_contacts
add constraint tax_contacts_calendar_email_when_enabled
check (not calendar_enabled or length(trim(coalesce(email, ''))) > 3);
create unique index if not exists tax_contacts_unique_active_bamboo_employee
on public.tax_contacts (bamboo_employee_id)
where active = true and bamboo_employee_id is not null;
create index if not exists tax_contacts_bamboo_employee_number_idx
on public.tax_contacts (bamboo_employee_number)
where bamboo_employee_number is not null;
-- Amplía la normalización existente para los nuevos campos.
create or replace function public.tax_normalize_contact()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
new.full_name := trim(new.full_name);
new.email := nullif(lower(trim(coalesce(new.email, ''))), '');
new.whatsapp_number := nullif(regexp_replace(coalesce(new.whatsapp_number, ''), '[^0-9]', '', 'g'), '');
new.job_title := nullif(trim(coalesce(new.job_title, '')), '');
new.department := nullif(trim(coalesce(new.department, '')), '');
new.division := nullif(trim(coalesce(new.division, '')), '');
new.location := nullif(trim(coalesce(new.location, '')), '');
new.source := coalesce(nullif(lower(trim(coalesce(new.source, ''))), ''), 'manual');
new.bamboo_employee_id := nullif(trim(coalesce(new.bamboo_employee_id, '')), '');
new.bamboo_employee_number := nullif(trim(coalesce(new.bamboo_employee_number, '')), '');
new.bamboo_status := nullif(trim(coalesce(new.bamboo_status, '')), '');
if new.source = 'bamboohr' then
if tg_op = 'INSERT' then
new.bamboo_synced_at := now();
elsif new.bamboo_employee_id is distinct from old.bamboo_employee_id
or new.full_name is distinct from old.full_name
or new.email is distinct from old.email
or new.whatsapp_number is distinct from old.whatsapp_number then
new.bamboo_synced_at := now();
end if;
end if;
return new;
end;
$$;
-- ---------------------------------------------------------------------------
-- No permitir obligaciones nuevas o movidas a fechas pasadas
-- ---------------------------------------------------------------------------
create or replace function public.tax_validate_future_obligation()
returns trigger
language plpgsql
set search_path = ''
as $$
declare
local_today date := (now() at time zone 'America/Santo_Domingo')::date;
begin
if new.active = true
and new.is_historical = false
and new.due_date < local_today then
if tg_op = 'INSERT' then
raise exception 'No se puede crear una obligación en una fecha que ya pasó.'
using errcode = '22007';
elsif new.due_date is distinct from old.due_date then
raise exception 'No se puede mover una obligación a una fecha que ya pasó.'
using errcode = '22007';
end if;
end if;
return new;
end;
$$;
drop trigger if exists tax_validate_future_obligation_trigger on public.tax_obligations;
create trigger tax_validate_future_obligation_trigger
before insert or update on public.tax_obligations
for each row execute function public.tax_validate_future_obligation();
-- ---------------------------------------------------------------------------
-- Sincronización con Google Calendar
-- Un evento de Google Calendar por obligación, con todos los destinatarios
-- aplicables como invitados.
-- ---------------------------------------------------------------------------
create table if not exists public.tax_google_calendar_events (
obligation_id uuid primary key references public.tax_obligations(id) on delete cascade,
google_event_id text,
calendar_id text not null default 'primary',
attendees jsonb not null default '[]'::jsonb,
status text not null default 'active'
check (status in ('active', 'deleted', 'failed')),
last_action text,
provider_response jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
synced_at timestamptz
);
alter table public.tax_google_calendar_events enable row level security;
revoke all on table public.tax_google_calendar_events from anon, authenticated;
grant select, insert, update, delete on table public.tax_google_calendar_events to service_role;
create or replace function public.tax_google_calendar_payload(p_obligation_id uuid)
returns table (
obligation_id uuid,
active boolean,
due_date date,
description text,
category text,
country_id uuid,
country_name text,
country_code text,
attendees jsonb,
google_event_id text,
calendar_id text,
sync_status text
)
language sql
stable
security definer
set search_path = ''
as $$
select
obligation.id,
obligation.active,
obligation.due_date,
obligation.description,
obligation.category,
obligation.country_id,
country.name,
country.code,
coalesce(
(
select jsonb_agg(recipient.email order by recipient.email)
from (
select distinct lower(contact.email) as email
from public.tax_contacts contact
join public.tax_countries contact_country
on contact_country.id = contact.country_id
where contact.active = true
and contact.calendar_enabled = true
and contact.email is not null
and length(trim(contact.email)) > 3
and (
contact.area = 'regional'
or (
contact_country.is_regional = true
and contact.area = obligation.category
)
or (
contact.country_id = obligation.country_id
and contact.area = obligation.category
)
)
) recipient
),
'[]'::jsonb
) as attendees,
sync.google_event_id,
coalesce(sync.calendar_id, 'primary') as calendar_id,
sync.status
from public.tax_obligations obligation
join public.tax_countries country on country.id = obligation.country_id
left join public.tax_google_calendar_events sync
on sync.obligation_id = obligation.id
where obligation.id = p_obligation_id;
$$;
revoke all on function public.tax_google_calendar_payload(uuid)
from public, anon, authenticated;
grant execute on function public.tax_google_calendar_payload(uuid)
to service_role;
create or replace function public.tax_record_google_calendar_sync(
p_obligation_id uuid,
p_google_event_id text,
p_calendar_id text default 'primary',
p_attendees jsonb default '[]'::jsonb,
p_status text default 'active',
p_last_action text default null,
p_provider_response jsonb default null
)
returns void
language plpgsql
security definer
set search_path = ''
as $$
begin
insert into public.tax_google_calendar_events (
obligation_id,
google_event_id,
calendar_id,
attendees,
status,
last_action,
provider_response,
synced_at,
updated_at
)
values (
p_obligation_id,
nullif(trim(coalesce(p_google_event_id, '')), ''),
coalesce(nullif(trim(coalesce(p_calendar_id, '')), ''), 'primary'),
coalesce(p_attendees, '[]'::jsonb),
p_status,
p_last_action,
p_provider_response,
now(),
now()
)
on conflict (obligation_id)
do update set
google_event_id = coalesce(excluded.google_event_id, public.tax_google_calendar_events.google_event_id),
calendar_id = excluded.calendar_id,
attendees = excluded.attendees,
status = excluded.status,
last_action = excluded.last_action,
provider_response = excluded.provider_response,
synced_at = now(),
updated_at = now();
end;
$$;
revoke all on function public.tax_record_google_calendar_sync(uuid, text, text, jsonb, text, text, jsonb)
from public, anon, authenticated;
grant execute on function public.tax_record_google_calendar_sync(uuid, text, text, jsonb, text, text, jsonb)
to service_role;
commit;
-- Verificaciones opcionales:
-- select * from public.tax_google_calendar_payload('UUID_DE_OBLIGACION');
-- select * from public.tax_google_calendar_events order by updated_at desc;
@@ -0,0 +1,174 @@
-- Seguimiento de Impuestos GLM
-- Actualización incremental: retirar WhatsApp y habilitar Google Chat.
-- Ejecutar después de actualizacion_bamboohr_google_calendar.sql.
begin;
alter table public.tax_contacts
add column if not exists google_chat_enabled boolean not null default true;
alter table public.tax_contacts
alter column whatsapp_enabled set default false;
-- La nueva operación deja de usar WhatsApp. Se conserva la información histórica
-- para no destruir datos ni bitácoras anteriores.
update public.tax_contacts
set
whatsapp_enabled = false,
google_chat_enabled = case
when email_enabled = true and email is not null and length(trim(email)) > 3 then true
else false
end;
alter table public.tax_contacts
drop constraint if exists tax_contacts_google_chat_email_when_enabled;
alter table public.tax_contacts
add constraint tax_contacts_google_chat_email_when_enabled
check (not google_chat_enabled or length(trim(coalesce(email, ''))) > 3);
alter table public.tax_notification_log
drop constraint if exists tax_notification_log_channel_check;
alter table public.tax_notification_log
add constraint tax_notification_log_channel_check
check (channel in ('email', 'google_chat', 'whatsapp'));
-- CREATE OR REPLACE no permite cambiar las columnas retornadas, por eso se elimina
-- y se recrea la RPC usada por n8n.
drop function if exists public.tax_due_reminders(date);
create function public.tax_due_reminders(p_run_date date default current_date)
returns table (
obligation_id uuid,
contact_id uuid,
alert_date date,
alert_type text,
due_date date,
description text,
category text,
country_name text,
country_code text,
contact_name text,
contact_email text,
email_enabled boolean,
google_chat_enabled boolean
)
language sql
stable
security definer
set search_path = ''
as $$
with due as (
select
obligation.id,
obligation.due_date,
obligation.description,
obligation.category,
obligation.country_id,
country.name as country_name,
country.code as country_code,
case
when p_run_date = obligation.due_date then 'same_day'
else 'previous_wednesday'
end as alert_type
from public.tax_obligations obligation
join public.tax_countries country on country.id = obligation.country_id
where obligation.active = true
and (
p_run_date = obligation.due_date
or p_run_date = public.tax_previous_wednesday(obligation.due_date)
)
)
select
due.id as obligation_id,
contact.id as contact_id,
p_run_date as alert_date,
due.alert_type,
due.due_date,
due.description,
due.category,
due.country_name,
due.country_code,
contact.full_name as contact_name,
contact.email as contact_email,
(
contact.email_enabled
and contact.email is not null
and not exists (
select 1
from public.tax_notification_log log
where log.obligation_id = due.id
and log.contact_id = contact.id
and log.alert_date = p_run_date
and log.channel = 'email'
and log.status = 'sent'
)
) as email_enabled,
(
contact.google_chat_enabled
and contact.email is not null
and not exists (
select 1
from public.tax_notification_log log
where log.obligation_id = due.id
and log.contact_id = contact.id
and log.alert_date = p_run_date
and log.channel = 'google_chat'
and log.status = 'sent'
)
) as google_chat_enabled
from due
join public.tax_contacts contact on contact.active = true
join public.tax_countries contact_country
on contact_country.id = contact.country_id
and (
contact.area = 'regional'
or (
contact_country.is_regional = true
and contact.area = due.category
)
or (
contact.country_id = due.country_id
and contact.area = due.category
)
)
where
(
contact.email_enabled
and contact.email is not null
and not exists (
select 1
from public.tax_notification_log log
where log.obligation_id = due.id
and log.contact_id = contact.id
and log.alert_date = p_run_date
and log.channel = 'email'
and log.status = 'sent'
)
)
or
(
contact.google_chat_enabled
and contact.email is not null
and not exists (
select 1
from public.tax_notification_log log
where log.obligation_id = due.id
and log.contact_id = contact.id
and log.alert_date = p_run_date
and log.channel = 'google_chat'
and log.status = 'sent'
)
)
order by due.due_date, due.country_name, contact.full_name;
$$;
revoke all on function public.tax_due_reminders(date) from public, anon, authenticated;
grant execute on function public.tax_due_reminders(date) to service_role;
commit;
-- Verificación opcional:
-- select full_name, email_enabled, google_chat_enabled, calendar_enabled, whatsapp_enabled
-- from public.tax_contacts where active = true order by full_name;