feat: versión inicial de GLM Hub
@@ -0,0 +1,4 @@
|
|||||||
|
VITE_SUPABASE_URL="https://tu-supabase.example.com"
|
||||||
|
VITE_SUPABASE_ANON_KEY="TU_SUPABASE_ANON_KEY"
|
||||||
|
VITE_ICON_GENERATOR_WEBHOOK_URL="https://tu-n8n.example.com/webhook/glm-hub-generar-icono"
|
||||||
|
VITE_ACCESS_REQUEST_WEBHOOK_URL="https://tu-n8n.example.com/webhook/glm-hub-solicitar-acceso"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
.vite/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# GLM Hub — correcciones del 28 de julio de 2026
|
||||||
|
|
||||||
|
## Aplicación
|
||||||
|
|
||||||
|
- La renovación automática del token de Supabase ya no reinicia la vista, no regresa al Hub y no vuelve a cargar el catálogo cuando el usuario cambia de pestaña y regresa.
|
||||||
|
- El formulario de Administración conserva el texto, selección, scroll y pantalla activa durante los eventos `SIGNED_IN` repetidos y `TOKEN_REFRESHED` del mismo usuario.
|
||||||
|
- La solicitud de generación usa un POST `text/plain` con el JWT dentro del cuerpo para evitar que un preflight CORS bloquee la llamada antes de llegar a n8n.
|
||||||
|
- El frontend acepta directamente la imagen binaria devuelta por n8n y conserva compatibilidad con la respuesta JSON del workflow anterior.
|
||||||
|
- Se añadió timeout de 3 minutos y mensajes de error más claros.
|
||||||
|
|
||||||
|
## Workflow n8n
|
||||||
|
|
||||||
|
Archivo: `n8n/GLM-Hub-Generar-Icono-Gemini-Nodo-Nativo.json`
|
||||||
|
|
||||||
|
- Usa el nodo nativo **Google Gemini → Image → Generate an Image**.
|
||||||
|
- Modelo configurado: `models/gemini-3-pro-image`.
|
||||||
|
- La imagen se devuelve directamente como binario por el webhook.
|
||||||
|
- El webhook acepta el JWT en el cuerpo o en el encabezado Authorization.
|
||||||
|
- Continúa validando en Supabase que el usuario sea administrador.
|
||||||
|
- CORS está permitido para que funcione tanto en producción como durante pruebas locales.
|
||||||
|
|
||||||
|
## Único ajuste manual después de importar
|
||||||
|
|
||||||
|
Abre el nodo **Generate an image** y confirma la credencial existente **Isaac - Gemini Api Pago**. El ID interno de una credencial no puede incluirse de forma portable en un JSON exportado.
|
||||||
|
|
||||||
|
Antes de activar este workflow, desactiva el workflow anterior que utiliza la misma ruta `glm-hub-generar-icono`.
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# GLM Hub — Implementación compartida con Supabase e íconos con Gemini
|
||||||
|
|
||||||
|
Este proyecto queda preparado para que el catálogo sea compartido por todo el equipo. Las aplicaciones ya no se guardan como catálogo operativo en el navegador: se almacenan en Supabase y se actualizan en las sesiones abiertas mediante Realtime.
|
||||||
|
|
||||||
|
## Archivos principales
|
||||||
|
|
||||||
|
- `SUPABASE-GLM-HUB-COMPLETO.sql`: crea o actualiza todo lo necesario en Supabase.
|
||||||
|
- `n8n/GLM-Hub-Generar-Icono-Gemini.json`: workflow importable de n8n.
|
||||||
|
- `.env`: variables de la app web.
|
||||||
|
|
||||||
|
## Orden correcto de implementación
|
||||||
|
|
||||||
|
### 1. Ejecutar el SQL completo en Supabase
|
||||||
|
|
||||||
|
En Supabase Studio abre **SQL Editor**, pega el contenido completo de:
|
||||||
|
|
||||||
|
`SUPABASE-GLM-HUB-COMPLETO.sql`
|
||||||
|
|
||||||
|
Luego ejecútalo una sola vez. El script es reutilizable: usa `if not exists`, reemplaza funciones y recrea políticas de forma controlada.
|
||||||
|
|
||||||
|
El script configura:
|
||||||
|
|
||||||
|
- Lista de usuarios autorizados y roles.
|
||||||
|
- Los cinco administradores definidos para GLM Hub.
|
||||||
|
- Catálogo compartido `glm_hub_apps`.
|
||||||
|
- Favoritos personales `glm_hub_favorites`.
|
||||||
|
- Accesos recientes personales `glm_hub_recent_apps`.
|
||||||
|
- Bucket público `glm-hub-icons` con máximo de 1 MB.
|
||||||
|
- Políticas RLS: todos los usuarios activos ven el catálogo publicado; solo administradores pueden crear, editar y eliminar.
|
||||||
|
- Publicación de cambios de aplicaciones en Supabase Realtime.
|
||||||
|
- Función segura `glm_hub_icon_generation_access()` para validar desde n8n que quien solicita el ícono sea administrador.
|
||||||
|
|
||||||
|
### 2. Importar y configurar el workflow de n8n
|
||||||
|
|
||||||
|
Importa:
|
||||||
|
|
||||||
|
`n8n/GLM-Hub-Generar-Icono-Gemini.json`
|
||||||
|
|
||||||
|
Configura estas variables de entorno en la instancia de n8n:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SUPABASE_URL=https://dbit.digitalcompass.agency
|
||||||
|
SUPABASE_ANON_KEY=TU_CLAVE_ANON_DE_SUPABASE
|
||||||
|
GEMINI_API_KEY=TU_API_KEY_DE_GEMINI
|
||||||
|
```
|
||||||
|
|
||||||
|
Reinicia n8n después de añadirlas, abre el workflow y actívalo.
|
||||||
|
|
||||||
|
Webhook de producción esperado:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://agenteit.digitalcompass.agency/webhook/glm-hub-generar-icono
|
||||||
|
```
|
||||||
|
|
||||||
|
El workflow:
|
||||||
|
|
||||||
|
1. Recibe nombre y descripción desde GLM Hub.
|
||||||
|
2. Verifica el JWT activo de Supabase.
|
||||||
|
3. Confirma en la base de datos que el usuario sea administrador.
|
||||||
|
4. Genera un ícono cuadrado sin texto con Gemini.
|
||||||
|
5. Devuelve la imagen al formulario como PNG.
|
||||||
|
|
||||||
|
La clave de Gemini debe permanecer únicamente en n8n. No debe añadirse al `.env` de Vite ni enviarse al navegador.
|
||||||
|
|
||||||
|
### 3. Revisar las variables del frontend
|
||||||
|
|
||||||
|
El `.env` incluido contiene:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_SUPABASE_URL="https://dbit.digitalcompass.agency"
|
||||||
|
VITE_SUPABASE_ANON_KEY="..."
|
||||||
|
VITE_ICON_GENERATOR_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/glm-hub-generar-icono"
|
||||||
|
```
|
||||||
|
|
||||||
|
Cambia la URL del webhook solamente si el workflow se publica en otro dominio o ruta.
|
||||||
|
|
||||||
|
### 4. Instalar, validar y compilar
|
||||||
|
|
||||||
|
Desde la carpeta raíz del proyecto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
La compilación queda en `dist/`.
|
||||||
|
|
||||||
|
### 5. Migrar las aplicaciones que ya estaban creadas
|
||||||
|
|
||||||
|
Después de ejecutar el SQL y desplegar esta versión:
|
||||||
|
|
||||||
|
1. Inicia sesión como administrador desde el mismo navegador donde estaban guardadas las aplicaciones anteriores.
|
||||||
|
2. La app compara el catálogo local con `glm_hub_apps` y migra solamente las aplicaciones cuyo nombre todavía no exista en Supabase.
|
||||||
|
3. Los íconos se suben al bucket `glm-hub-icons`.
|
||||||
|
4. Desde ese momento, cualquier usuario autorizado verá el mismo catálogo publicado al iniciar sesión.
|
||||||
|
|
||||||
|
La comparación por nombre evita duplicados y permite reanudar la migración en otro inicio de sesión si una carga anterior quedó incompleta. Cuando termina correctamente, el navegador registra la migración como completada para que una aplicación eliminada después desde Supabase no vuelva a crearse desde el catálogo local antiguo.
|
||||||
|
|
||||||
|
## Funcionamiento del Hub
|
||||||
|
|
||||||
|
- **Acceso rápido:** incluye todas las aplicaciones marcadas como favoritas por el usuario. Si todavía tiene menos de tres favoritas, se completa temporalmente con recientes o aplicaciones disponibles. Se muestran seis por página y la paginación aparece solo cuando hace falta.
|
||||||
|
- **Todas las aplicaciones:** muestra diez por página en Todas, Administración, Recursos Humanos y CDC. En cada filtro, los favoritos se ordenan antes que el resto y después se aplica la paginación.
|
||||||
|
- **Administración:** también pagina el catálogo cada diez registros.
|
||||||
|
- **Cambios compartidos:** al crear, editar o eliminar una aplicación, el cambio se guarda en Supabase y llega al resto de sesiones abiertas.
|
||||||
|
- **Favoritos y recientes:** son personales para cada usuario; no afectan la vista de los demás.
|
||||||
|
|
||||||
|
## Generación del ícono
|
||||||
|
|
||||||
|
El botón **Generar ícono con IA** se habilita solamente cuando se completan:
|
||||||
|
|
||||||
|
- Nombre de la aplicación.
|
||||||
|
- Descripción breve.
|
||||||
|
|
||||||
|
Mientras Gemini está trabajando:
|
||||||
|
|
||||||
|
- Se muestra una pantalla de carga bloqueante.
|
||||||
|
- No se puede publicar, cancelar, editar ni salir accidentalmente de la página.
|
||||||
|
- Al finalizar, el ícono aparece en la vista previa y puede publicarse junto con la aplicación.
|
||||||
|
|
||||||
|
## Dar o quitar acceso
|
||||||
|
|
||||||
|
Desde `public.glm_hub_authorized_users`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Dar acceso normal
|
||||||
|
insert into public.glm_hub_authorized_users (email, full_name, role, is_active)
|
||||||
|
values ('persona@gomezleemarketing.com', 'Nombre de la persona', 'member', true);
|
||||||
|
|
||||||
|
-- Quitar acceso sin borrar
|
||||||
|
update public.glm_hub_authorized_users
|
||||||
|
set is_active = false
|
||||||
|
where email = 'persona@gomezleemarketing.com';
|
||||||
|
|
||||||
|
-- Reactivar
|
||||||
|
update public.glm_hub_authorized_users
|
||||||
|
set is_active = true
|
||||||
|
where email = 'persona@gomezleemarketing.com';
|
||||||
|
```
|
||||||
|
|
||||||
|
Los administradores están restringidos por una validación de base de datos a los cinco correos definidos en el script.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# GLM Hub — Solicitudes de acceso
|
||||||
|
|
||||||
|
## Cambios incluidos
|
||||||
|
|
||||||
|
- Se retiró el texto de ayuda **“Directa y fácil de escanear.”** debajo de Descripción breve.
|
||||||
|
- **Control del catálogo** sigue renderizándose exclusivamente para usuarios con `role = admin`.
|
||||||
|
- Los usuarios normales ven una nueva sección **¿No tienes acceso a una aplicación?**.
|
||||||
|
- El formulario de solicitud se alimenta directamente del catálogo publicado en Supabase, por lo que al crear, ocultar o eliminar aplicaciones el dropdown se actualiza sin editar código.
|
||||||
|
- La pantalla queda bloqueada mientras n8n registra la solicitud y envía los correos.
|
||||||
|
- Los correos de aprobación se envían individualmente a Isaac Aracena, José Leopoldo Gómez y Máximo Gómez.
|
||||||
|
- La primera decisión confirmada queda registrada de forma atómica en Supabase. Los clics posteriores no pueden cambiarla ni volver a avisar a IT Support.
|
||||||
|
- Tras la decisión se envía un correo HTML a `itsupport@gomezleemarketing.com` con solicitante, aplicación, decisión y persona que decidió.
|
||||||
|
|
||||||
|
## 1. Supabase
|
||||||
|
|
||||||
|
Ejecuta completo:
|
||||||
|
|
||||||
|
`SUPABASE-GLM-HUB-COMPLETO.sql`
|
||||||
|
|
||||||
|
El script conserva las tablas anteriores y agrega:
|
||||||
|
|
||||||
|
- `glm_hub_access_requests`
|
||||||
|
- `glm_hub_access_request_approvers`
|
||||||
|
- `glm_hub_prepare_access_request(uuid)`
|
||||||
|
- `glm_hub_issue_access_request_tokens(uuid)`
|
||||||
|
- `glm_hub_decide_access_request(text, text)`
|
||||||
|
|
||||||
|
La función que entrega los enlaces de aprobación solo puede ejecutarse con `service_role`; los enlaces nunca se devuelven al navegador del solicitante.
|
||||||
|
|
||||||
|
## 2. n8n
|
||||||
|
|
||||||
|
Importa:
|
||||||
|
|
||||||
|
`n8n/GLM-Hub-Solicitudes-Acceso-Aprobacion.json`
|
||||||
|
|
||||||
|
Después configura:
|
||||||
|
|
||||||
|
1. En **Crear solicitud en Supabase** reemplaza `TU_SUPABASE_ANON_KEY` en `apikey`.
|
||||||
|
2. En **Emitir enlaces de aprobación** reemplaza `TU_SUPABASE_SERVICE_ROLE_KEY` en `apikey` y `Authorization`.
|
||||||
|
3. En **Registrar decisión en Supabase** reemplaza `TU_SUPABASE_ANON_KEY` en `apikey` y dentro de `Bearer TU_SUPABASE_ANON_KEY`.
|
||||||
|
4. Selecciona una credencial Gmail válida en:
|
||||||
|
- **Enviar solicitud a aprobadores**
|
||||||
|
- **Enviar decisión a IT Support**
|
||||||
|
5. Guarda, publica y activa el workflow.
|
||||||
|
|
||||||
|
El workflow no usa variables de entorno de n8n.
|
||||||
|
|
||||||
|
## 3. Frontend
|
||||||
|
|
||||||
|
El `.env` ya incluye:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_ACCESS_REQUEST_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/glm-hub-solicitar-acceso"
|
||||||
|
```
|
||||||
|
|
||||||
|
El ZIP también contiene un `dist` actualizado. La versión fuente puede recompilarse normalmente con:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decisión desde correo
|
||||||
|
|
||||||
|
Los botones del correo abren una pantalla de confirmación antes de registrar la decisión. Esto evita que un escáner automático de enlaces de Gmail o seguridad corporativa apruebe o rechace una solicitud sin intervención humana.
|
||||||
|
|
||||||
|
Los botones permanecen visibles en los correos ya entregados, pero después de la primera decisión Supabase bloquea cualquier cambio posterior y muestra que la solicitud ya fue atendida.
|
||||||
|
|
||||||
|
|
||||||
|
## Corrección 2026-07-29
|
||||||
|
|
||||||
|
- El nodo **Crear solicitud en Supabase** usa la clave `anon` en `apikey` y el JWT vigente del usuario en `Authorization`.
|
||||||
|
- Los nodos internos para emitir enlaces y registrar decisiones conservan la clave `service_role`.
|
||||||
|
- Los errores visibles en el portal son mensajes para usuarios; los detalles técnicos quedan solamente en la ejecución de n8n.
|
||||||
|
- El selector de áreas incluye **CDC**.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# GLM Hub — Supabase compartido
|
||||||
|
|
||||||
|
La guía vigente está en:
|
||||||
|
|
||||||
|
`README-IMPLEMENTACION-COMPLETA.md`
|
||||||
|
|
||||||
|
Ejecuta únicamente el script completo:
|
||||||
|
|
||||||
|
`SUPABASE-GLM-HUB-COMPLETO.sql`
|
||||||
|
|
||||||
|
El catálogo, los íconos, favoritos, recientes, RLS y Realtime están incluidos en ese único archivo.
|
||||||
@@ -0,0 +1,894 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- GLM HUB — ESQUEMA COMPLETO DE SUPABASE
|
||||||
|
-- ============================================================================
|
||||||
|
-- Incluye:
|
||||||
|
-- 1) Usuarios autorizados y roles.
|
||||||
|
-- 2) Catálogo compartido para todos los usuarios del Hub.
|
||||||
|
-- 3) Favoritos y accesos recientes por usuario.
|
||||||
|
-- 4) Bucket público para los íconos.
|
||||||
|
-- 5) RLS, permisos y Realtime.
|
||||||
|
-- 6) Función segura que usa n8n para validar al administrador.
|
||||||
|
-- 7) Solicitudes de acceso, aprobadores y decisión única.
|
||||||
|
--
|
||||||
|
-- Ejecutar el archivo completo una sola vez en:
|
||||||
|
-- Supabase Studio > SQL Editor > New query > Run.
|
||||||
|
-- El script es idempotente y puede volver a ejecutarse para restaurar políticas.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
create extension if not exists pgcrypto with schema extensions;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- 1. USUARIOS AUTORIZADOS
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
create table if not exists public.glm_hub_authorized_users (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
email text not null unique,
|
||||||
|
full_name text,
|
||||||
|
role text not null default 'member',
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
|
||||||
|
constraint glm_hub_authorized_users_role_check
|
||||||
|
check (role in ('admin', 'member')),
|
||||||
|
|
||||||
|
constraint glm_hub_authorized_users_email_normalized_check
|
||||||
|
check (email = lower(btrim(email))),
|
||||||
|
|
||||||
|
constraint glm_hub_authorized_users_domain_check
|
||||||
|
check (email ~ '^[a-z0-9.!#$%&''*+/=?^_`{|}~-]+@gomezleemarketing\.com$'),
|
||||||
|
|
||||||
|
constraint glm_hub_authorized_users_admin_check
|
||||||
|
check (
|
||||||
|
role <> 'admin'
|
||||||
|
or email in (
|
||||||
|
'jgomez@gomezleemarketing.com',
|
||||||
|
'iaracena@gomezleemarketing.com',
|
||||||
|
'ethen@gomezleemarketing.com',
|
||||||
|
'mgomez@gomezleemarketing.com',
|
||||||
|
'lmatos@gomezleemarketing.com'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create or replace function public.glm_hub_normalize_authorized_user()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
new.email := lower(btrim(new.email));
|
||||||
|
new.full_name := nullif(btrim(new.full_name), '');
|
||||||
|
new.updated_at := now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists glm_hub_normalize_authorized_user_trigger
|
||||||
|
on public.glm_hub_authorized_users;
|
||||||
|
|
||||||
|
create trigger glm_hub_normalize_authorized_user_trigger
|
||||||
|
before insert or update on public.glm_hub_authorized_users
|
||||||
|
for each row
|
||||||
|
execute function public.glm_hub_normalize_authorized_user();
|
||||||
|
|
||||||
|
alter table public.glm_hub_authorized_users enable row level security;
|
||||||
|
alter table public.glm_hub_authorized_users force row level security;
|
||||||
|
|
||||||
|
revoke all on table public.glm_hub_authorized_users from anon;
|
||||||
|
revoke all on table public.glm_hub_authorized_users from authenticated;
|
||||||
|
grant select on table public.glm_hub_authorized_users to authenticated;
|
||||||
|
grant all on table public.glm_hub_authorized_users to service_role;
|
||||||
|
|
||||||
|
drop policy if exists "GLM Hub users can read only their active access"
|
||||||
|
on public.glm_hub_authorized_users;
|
||||||
|
|
||||||
|
create policy "GLM Hub users can read only their active access"
|
||||||
|
on public.glm_hub_authorized_users
|
||||||
|
for select
|
||||||
|
to authenticated
|
||||||
|
using (
|
||||||
|
is_active = true
|
||||||
|
and email = lower(coalesce((select auth.jwt() ->> 'email'), ''))
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into public.glm_hub_authorized_users (email, full_name, role, is_active)
|
||||||
|
values
|
||||||
|
('jgomez@gomezleemarketing.com', 'José Leopoldo Gómez', 'admin', true),
|
||||||
|
('iaracena@gomezleemarketing.com', 'Isaac Aracena', 'admin', true),
|
||||||
|
('ethen@gomezleemarketing.com', 'Eidan Then', 'admin', true),
|
||||||
|
('mgomez@gomezleemarketing.com', 'Máximo Gomez', 'admin', true),
|
||||||
|
('lmatos@gomezleemarketing.com', 'Luis Matos', 'admin', true)
|
||||||
|
on conflict (email) do update
|
||||||
|
set
|
||||||
|
full_name = excluded.full_name,
|
||||||
|
role = excluded.role,
|
||||||
|
is_active = true,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- Estas funciones se ejecutan con los permisos/RLS del usuario autenticado.
|
||||||
|
create or replace function public.glm_hub_is_active_user()
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security invoker
|
||||||
|
set search_path = public, auth
|
||||||
|
as $$
|
||||||
|
select exists (
|
||||||
|
select 1
|
||||||
|
from public.glm_hub_authorized_users as access
|
||||||
|
where access.is_active = true
|
||||||
|
and access.email = lower(coalesce((select auth.jwt() ->> 'email'), ''))
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.glm_hub_is_admin()
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security invoker
|
||||||
|
set search_path = public, auth
|
||||||
|
as $$
|
||||||
|
select exists (
|
||||||
|
select 1
|
||||||
|
from public.glm_hub_authorized_users as access
|
||||||
|
where access.is_active = true
|
||||||
|
and access.role = 'admin'
|
||||||
|
and access.email = lower(coalesce((select auth.jwt() ->> 'email'), ''))
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- n8n llama esta función usando el JWT recibido desde el frontend.
|
||||||
|
-- SECURITY DEFINER permite consultar la tabla interna sin quedar bloqueado por RLS,
|
||||||
|
-- pero el correo y el usuario siempre se toman del JWT real de Supabase.
|
||||||
|
create or replace function public.glm_hub_icon_generation_access()
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_email text;
|
||||||
|
v_user_id text;
|
||||||
|
v_authorized boolean;
|
||||||
|
begin
|
||||||
|
v_email := lower(coalesce(auth.jwt() ->> 'email', ''));
|
||||||
|
v_user_id := coalesce(auth.uid()::text, '');
|
||||||
|
|
||||||
|
select exists (
|
||||||
|
select 1
|
||||||
|
from public.glm_hub_authorized_users as access
|
||||||
|
where lower(access.email) = v_email
|
||||||
|
and access.role = 'admin'
|
||||||
|
and access.is_active = true
|
||||||
|
)
|
||||||
|
into v_authorized;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'authorized', coalesce(v_authorized, false),
|
||||||
|
'email', v_email,
|
||||||
|
'userId', v_user_id
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
alter function public.glm_hub_icon_generation_access() owner to postgres;
|
||||||
|
|
||||||
|
revoke all on function public.glm_hub_is_active_user() from public, anon;
|
||||||
|
revoke all on function public.glm_hub_is_admin() from public, anon;
|
||||||
|
revoke all on function public.glm_hub_icon_generation_access() from public, anon;
|
||||||
|
grant execute on function public.glm_hub_is_active_user() to authenticated;
|
||||||
|
grant execute on function public.glm_hub_is_admin() to authenticated;
|
||||||
|
grant execute on function public.glm_hub_icon_generation_access() to authenticated;
|
||||||
|
grant execute on function public.glm_hub_is_active_user() to service_role;
|
||||||
|
grant execute on function public.glm_hub_is_admin() to service_role;
|
||||||
|
grant execute on function public.glm_hub_icon_generation_access() to service_role;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- 2. CATÁLOGO COMPARTIDO
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
create table if not exists public.glm_hub_apps (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
name text not null,
|
||||||
|
description text not null,
|
||||||
|
category text not null,
|
||||||
|
url text,
|
||||||
|
icon_url text not null,
|
||||||
|
visibility text not null default 'published',
|
||||||
|
created_by uuid default auth.uid() references auth.users(id) on delete set null,
|
||||||
|
updated_by uuid default auth.uid() references auth.users(id) on delete set null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
|
||||||
|
constraint glm_hub_apps_name_length_check
|
||||||
|
check (char_length(btrim(name)) between 2 and 60),
|
||||||
|
constraint glm_hub_apps_description_length_check
|
||||||
|
check (char_length(btrim(description)) between 1 and 180),
|
||||||
|
constraint glm_hub_apps_category_check
|
||||||
|
check (category in ('Administración', 'Recursos Humanos', 'CDC')),
|
||||||
|
constraint glm_hub_apps_visibility_check
|
||||||
|
check (visibility in ('published', 'hidden')),
|
||||||
|
constraint glm_hub_apps_icon_url_check
|
||||||
|
check (icon_url ~ '^https://')
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index if not exists glm_hub_apps_name_unique_ci
|
||||||
|
on public.glm_hub_apps (lower(btrim(name)));
|
||||||
|
|
||||||
|
create index if not exists glm_hub_apps_visibility_category_name_idx
|
||||||
|
on public.glm_hub_apps (visibility, category, name);
|
||||||
|
|
||||||
|
create or replace function public.glm_hub_normalize_app()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
new.name := btrim(new.name);
|
||||||
|
new.description := btrim(new.description);
|
||||||
|
new.category := btrim(new.category);
|
||||||
|
new.url := nullif(btrim(new.url), '');
|
||||||
|
new.icon_url := btrim(new.icon_url);
|
||||||
|
new.updated_at := now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists glm_hub_normalize_app_trigger on public.glm_hub_apps;
|
||||||
|
create trigger glm_hub_normalize_app_trigger
|
||||||
|
before insert or update on public.glm_hub_apps
|
||||||
|
for each row
|
||||||
|
execute function public.glm_hub_normalize_app();
|
||||||
|
|
||||||
|
alter table public.glm_hub_apps enable row level security;
|
||||||
|
alter table public.glm_hub_apps force row level security;
|
||||||
|
alter table public.glm_hub_apps replica identity full;
|
||||||
|
|
||||||
|
revoke all on table public.glm_hub_apps from anon;
|
||||||
|
revoke all on table public.glm_hub_apps from authenticated;
|
||||||
|
grant select, insert, update, delete on table public.glm_hub_apps to authenticated;
|
||||||
|
grant all on table public.glm_hub_apps to service_role;
|
||||||
|
|
||||||
|
drop policy if exists "GLM Hub active users read shared apps" on public.glm_hub_apps;
|
||||||
|
drop policy if exists "GLM Hub admins create apps" on public.glm_hub_apps;
|
||||||
|
drop policy if exists "GLM Hub admins update apps" on public.glm_hub_apps;
|
||||||
|
drop policy if exists "GLM Hub admins delete apps" on public.glm_hub_apps;
|
||||||
|
|
||||||
|
create policy "GLM Hub active users read shared apps"
|
||||||
|
on public.glm_hub_apps
|
||||||
|
for select
|
||||||
|
to authenticated
|
||||||
|
using (
|
||||||
|
public.glm_hub_is_active_user()
|
||||||
|
and (visibility = 'published' or public.glm_hub_is_admin())
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy "GLM Hub admins create apps"
|
||||||
|
on public.glm_hub_apps
|
||||||
|
for insert
|
||||||
|
to authenticated
|
||||||
|
with check (
|
||||||
|
public.glm_hub_is_admin()
|
||||||
|
and created_by = (select auth.uid())
|
||||||
|
and updated_by = (select auth.uid())
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy "GLM Hub admins update apps"
|
||||||
|
on public.glm_hub_apps
|
||||||
|
for update
|
||||||
|
to authenticated
|
||||||
|
using (public.glm_hub_is_admin())
|
||||||
|
with check (
|
||||||
|
public.glm_hub_is_admin()
|
||||||
|
and updated_by = (select auth.uid())
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy "GLM Hub admins delete apps"
|
||||||
|
on public.glm_hub_apps
|
||||||
|
for delete
|
||||||
|
to authenticated
|
||||||
|
using (public.glm_hub_is_admin());
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- 3. FAVORITOS POR USUARIO
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
create table if not exists public.glm_hub_favorites (
|
||||||
|
user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
app_id uuid not null references public.glm_hub_apps(id) on delete cascade,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
primary key (user_id, app_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists glm_hub_favorites_user_created_idx
|
||||||
|
on public.glm_hub_favorites (user_id, created_at desc);
|
||||||
|
|
||||||
|
alter table public.glm_hub_favorites enable row level security;
|
||||||
|
alter table public.glm_hub_favorites force row level security;
|
||||||
|
|
||||||
|
revoke all on table public.glm_hub_favorites from anon;
|
||||||
|
revoke all on table public.glm_hub_favorites from authenticated;
|
||||||
|
grant select, insert, update, delete on table public.glm_hub_favorites to authenticated;
|
||||||
|
grant all on table public.glm_hub_favorites to service_role;
|
||||||
|
|
||||||
|
drop policy if exists "GLM Hub users read their favorites" on public.glm_hub_favorites;
|
||||||
|
drop policy if exists "GLM Hub users create their favorites" on public.glm_hub_favorites;
|
||||||
|
drop policy if exists "GLM Hub users update their favorites" on public.glm_hub_favorites;
|
||||||
|
drop policy if exists "GLM Hub users delete their favorites" on public.glm_hub_favorites;
|
||||||
|
|
||||||
|
create policy "GLM Hub users read their favorites"
|
||||||
|
on public.glm_hub_favorites
|
||||||
|
for select
|
||||||
|
to authenticated
|
||||||
|
using (public.glm_hub_is_active_user() and user_id = (select auth.uid()));
|
||||||
|
|
||||||
|
create policy "GLM Hub users create their favorites"
|
||||||
|
on public.glm_hub_favorites
|
||||||
|
for insert
|
||||||
|
to authenticated
|
||||||
|
with check (public.glm_hub_is_active_user() and user_id = (select auth.uid()));
|
||||||
|
|
||||||
|
create policy "GLM Hub users update their favorites"
|
||||||
|
on public.glm_hub_favorites
|
||||||
|
for update
|
||||||
|
to authenticated
|
||||||
|
using (public.glm_hub_is_active_user() and user_id = (select auth.uid()))
|
||||||
|
with check (public.glm_hub_is_active_user() and user_id = (select auth.uid()));
|
||||||
|
|
||||||
|
create policy "GLM Hub users delete their favorites"
|
||||||
|
on public.glm_hub_favorites
|
||||||
|
for delete
|
||||||
|
to authenticated
|
||||||
|
using (public.glm_hub_is_active_user() and user_id = (select auth.uid()));
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- 4. ACCESOS RECIENTES POR USUARIO
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
create table if not exists public.glm_hub_recent_apps (
|
||||||
|
user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
app_id uuid not null references public.glm_hub_apps(id) on delete cascade,
|
||||||
|
last_opened_at timestamptz not null default now(),
|
||||||
|
primary key (user_id, app_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists glm_hub_recent_apps_user_date_idx
|
||||||
|
on public.glm_hub_recent_apps (user_id, last_opened_at desc);
|
||||||
|
|
||||||
|
alter table public.glm_hub_recent_apps enable row level security;
|
||||||
|
alter table public.glm_hub_recent_apps force row level security;
|
||||||
|
|
||||||
|
revoke all on table public.glm_hub_recent_apps from anon;
|
||||||
|
revoke all on table public.glm_hub_recent_apps from authenticated;
|
||||||
|
grant select, insert, update, delete on table public.glm_hub_recent_apps to authenticated;
|
||||||
|
grant all on table public.glm_hub_recent_apps to service_role;
|
||||||
|
|
||||||
|
drop policy if exists "GLM Hub users read their recent apps" on public.glm_hub_recent_apps;
|
||||||
|
drop policy if exists "GLM Hub users create their recent apps" on public.glm_hub_recent_apps;
|
||||||
|
drop policy if exists "GLM Hub users update their recent apps" on public.glm_hub_recent_apps;
|
||||||
|
drop policy if exists "GLM Hub users delete their recent apps" on public.glm_hub_recent_apps;
|
||||||
|
|
||||||
|
create policy "GLM Hub users read their recent apps"
|
||||||
|
on public.glm_hub_recent_apps
|
||||||
|
for select
|
||||||
|
to authenticated
|
||||||
|
using (public.glm_hub_is_active_user() and user_id = (select auth.uid()));
|
||||||
|
|
||||||
|
create policy "GLM Hub users create their recent apps"
|
||||||
|
on public.glm_hub_recent_apps
|
||||||
|
for insert
|
||||||
|
to authenticated
|
||||||
|
with check (public.glm_hub_is_active_user() and user_id = (select auth.uid()));
|
||||||
|
|
||||||
|
create policy "GLM Hub users update their recent apps"
|
||||||
|
on public.glm_hub_recent_apps
|
||||||
|
for update
|
||||||
|
to authenticated
|
||||||
|
using (public.glm_hub_is_active_user() and user_id = (select auth.uid()))
|
||||||
|
with check (public.glm_hub_is_active_user() and user_id = (select auth.uid()));
|
||||||
|
|
||||||
|
create policy "GLM Hub users delete their recent apps"
|
||||||
|
on public.glm_hub_recent_apps
|
||||||
|
for delete
|
||||||
|
to authenticated
|
||||||
|
using (public.glm_hub_is_active_user() and user_id = (select auth.uid()));
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- 5. STORAGE PARA LOS ÍCONOS
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
insert into storage.buckets (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
public,
|
||||||
|
file_size_limit,
|
||||||
|
allowed_mime_types
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
'glm-hub-icons',
|
||||||
|
'glm-hub-icons',
|
||||||
|
true,
|
||||||
|
1048576,
|
||||||
|
array['image/png', 'image/jpeg', 'image/webp']::text[]
|
||||||
|
)
|
||||||
|
on conflict (id) do update
|
||||||
|
set
|
||||||
|
name = excluded.name,
|
||||||
|
public = true,
|
||||||
|
file_size_limit = excluded.file_size_limit,
|
||||||
|
allowed_mime_types = excluded.allowed_mime_types;
|
||||||
|
|
||||||
|
drop policy if exists "GLM Hub active users read icons" on storage.objects;
|
||||||
|
drop policy if exists "GLM Hub admins upload icons" on storage.objects;
|
||||||
|
drop policy if exists "GLM Hub admins update icons" on storage.objects;
|
||||||
|
drop policy if exists "GLM Hub admins delete icons" on storage.objects;
|
||||||
|
|
||||||
|
create policy "GLM Hub active users read icons"
|
||||||
|
on storage.objects
|
||||||
|
for select
|
||||||
|
to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'glm-hub-icons'
|
||||||
|
and public.glm_hub_is_active_user()
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy "GLM Hub admins upload icons"
|
||||||
|
on storage.objects
|
||||||
|
for insert
|
||||||
|
to authenticated
|
||||||
|
with check (
|
||||||
|
bucket_id = 'glm-hub-icons'
|
||||||
|
and public.glm_hub_is_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy "GLM Hub admins update icons"
|
||||||
|
on storage.objects
|
||||||
|
for update
|
||||||
|
to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'glm-hub-icons'
|
||||||
|
and public.glm_hub_is_admin()
|
||||||
|
)
|
||||||
|
with check (
|
||||||
|
bucket_id = 'glm-hub-icons'
|
||||||
|
and public.glm_hub_is_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy "GLM Hub admins delete icons"
|
||||||
|
on storage.objects
|
||||||
|
for delete
|
||||||
|
to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'glm-hub-icons'
|
||||||
|
and public.glm_hub_is_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- 6. SOLICITUDES DE ACCESO A APLICACIONES
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
create table if not exists public.glm_hub_access_requests (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
requester_user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
requester_name text not null,
|
||||||
|
requester_email text not null,
|
||||||
|
app_id uuid references public.glm_hub_apps(id) on delete set null,
|
||||||
|
app_name text not null,
|
||||||
|
app_category text not null,
|
||||||
|
status text not null default 'pending',
|
||||||
|
decided_by_name text,
|
||||||
|
decided_by_email text,
|
||||||
|
decided_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
|
||||||
|
constraint glm_hub_access_requests_status_check
|
||||||
|
check (status in ('pending', 'approved', 'rejected')),
|
||||||
|
constraint glm_hub_access_requests_requester_email_check
|
||||||
|
check (requester_email = lower(btrim(requester_email))),
|
||||||
|
constraint glm_hub_access_requests_category_check
|
||||||
|
check (app_category in ('Administración', 'Recursos Humanos', 'CDC'))
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index if not exists glm_hub_access_requests_one_pending_idx
|
||||||
|
on public.glm_hub_access_requests (requester_email, app_id)
|
||||||
|
where status = 'pending' and app_id is not null;
|
||||||
|
|
||||||
|
create index if not exists glm_hub_access_requests_status_created_idx
|
||||||
|
on public.glm_hub_access_requests (status, created_at desc);
|
||||||
|
|
||||||
|
create table if not exists public.glm_hub_access_request_approvers (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
request_id uuid not null references public.glm_hub_access_requests(id) on delete cascade,
|
||||||
|
approver_name text not null,
|
||||||
|
approver_email text not null,
|
||||||
|
decision_token text not null unique,
|
||||||
|
decision_token_hash text not null unique,
|
||||||
|
clicked_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
|
||||||
|
constraint glm_hub_access_request_approvers_email_check
|
||||||
|
check (
|
||||||
|
approver_email in (
|
||||||
|
'iaracena@gomezleemarketing.com',
|
||||||
|
'jgomez@gomezleemarketing.com',
|
||||||
|
'mgomez@gomezleemarketing.com'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
constraint glm_hub_access_request_approvers_unique_request_email
|
||||||
|
unique (request_id, approver_email)
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.glm_hub_access_requests enable row level security;
|
||||||
|
alter table public.glm_hub_access_requests force row level security;
|
||||||
|
alter table public.glm_hub_access_request_approvers enable row level security;
|
||||||
|
alter table public.glm_hub_access_request_approvers force row level security;
|
||||||
|
|
||||||
|
revoke all on table public.glm_hub_access_requests from anon, authenticated;
|
||||||
|
revoke all on table public.glm_hub_access_request_approvers from anon, authenticated;
|
||||||
|
grant all on table public.glm_hub_access_requests to service_role;
|
||||||
|
grant all on table public.glm_hub_access_request_approvers to service_role;
|
||||||
|
|
||||||
|
-- Los usuarios pueden consultar solamente sus propias solicitudes desde Supabase,
|
||||||
|
-- aunque el frontend actual no necesita leer esta tabla.
|
||||||
|
drop policy if exists "GLM Hub users read their access requests"
|
||||||
|
on public.glm_hub_access_requests;
|
||||||
|
|
||||||
|
create policy "GLM Hub users read their access requests"
|
||||||
|
on public.glm_hub_access_requests
|
||||||
|
for select
|
||||||
|
to authenticated
|
||||||
|
using (
|
||||||
|
requester_user_id = auth.uid()
|
||||||
|
and public.glm_hub_is_active_user()
|
||||||
|
);
|
||||||
|
|
||||||
|
grant select on table public.glm_hub_access_requests to authenticated;
|
||||||
|
|
||||||
|
-- Registra la solicitud usando exclusivamente la identidad del JWT del usuario.
|
||||||
|
-- No devuelve enlaces de aprobación al navegador, evitando que el solicitante
|
||||||
|
-- pueda obtenerlos o aprobarse a sí mismo.
|
||||||
|
create or replace function public.glm_hub_prepare_access_request(p_app_id uuid)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
volatile
|
||||||
|
security definer
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_user_id uuid;
|
||||||
|
v_email text;
|
||||||
|
v_name text;
|
||||||
|
v_role text;
|
||||||
|
v_app_name text;
|
||||||
|
v_app_category text;
|
||||||
|
v_request_id uuid;
|
||||||
|
begin
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
v_email := lower(coalesce(auth.jwt() ->> 'email', ''));
|
||||||
|
|
||||||
|
if v_user_id is null or v_email = '' then
|
||||||
|
return jsonb_build_object('ok', false, 'error', 'Tu sesión no es válida. Cierra sesión e inicia nuevamente.');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select coalesce(nullif(btrim(access.full_name), ''), split_part(v_email, '@', 1)), access.role
|
||||||
|
into v_name, v_role
|
||||||
|
from public.glm_hub_authorized_users as access
|
||||||
|
where access.email = v_email
|
||||||
|
and access.is_active = true
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
return jsonb_build_object('ok', false, 'error', 'El usuario no está autorizado en GLM Hub.');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_role = 'admin' then
|
||||||
|
return jsonb_build_object('ok', false, 'error', 'Los administradores no necesitan solicitar acceso desde el Hub.');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select app.name, app.category
|
||||||
|
into v_app_name, v_app_category
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
where app.id = p_app_id
|
||||||
|
and app.visibility = 'published'
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
return jsonb_build_object('ok', false, 'error', 'La aplicación seleccionada ya no está publicada.');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if exists (
|
||||||
|
select 1
|
||||||
|
from public.glm_hub_access_requests as request
|
||||||
|
where request.requester_email = v_email
|
||||||
|
and request.app_id = p_app_id
|
||||||
|
and request.status = 'pending'
|
||||||
|
) then
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', false,
|
||||||
|
'code', 'PENDING_EXISTS',
|
||||||
|
'error', 'Ya existe una solicitud pendiente para esta aplicación.'
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.glm_hub_access_requests (
|
||||||
|
requester_user_id,
|
||||||
|
requester_name,
|
||||||
|
requester_email,
|
||||||
|
app_id,
|
||||||
|
app_name,
|
||||||
|
app_category
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
v_user_id,
|
||||||
|
v_name,
|
||||||
|
v_email,
|
||||||
|
p_app_id,
|
||||||
|
v_app_name,
|
||||||
|
v_app_category
|
||||||
|
)
|
||||||
|
returning id into v_request_id;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'requestId', v_request_id,
|
||||||
|
'requesterName', v_name,
|
||||||
|
'requesterEmail', v_email,
|
||||||
|
'appName', v_app_name,
|
||||||
|
'appCategory', v_app_category
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
alter function public.glm_hub_prepare_access_request(uuid) owner to postgres;
|
||||||
|
revoke all on function public.glm_hub_prepare_access_request(uuid) from public, anon;
|
||||||
|
grant execute on function public.glm_hub_prepare_access_request(uuid) to authenticated;
|
||||||
|
grant execute on function public.glm_hub_prepare_access_request(uuid) to service_role;
|
||||||
|
|
||||||
|
-- Emite o recupera los tres enlaces de decisión. Solo n8n, usando la clave
|
||||||
|
-- service_role guardada en su nodo HTTP Request, puede ejecutar esta función.
|
||||||
|
create or replace function public.glm_hub_issue_access_request_tokens(p_request_id uuid)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
volatile
|
||||||
|
security definer
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_request public.glm_hub_access_requests%rowtype;
|
||||||
|
v_token_isaac text;
|
||||||
|
v_token_jose text;
|
||||||
|
v_token_maximo text;
|
||||||
|
begin
|
||||||
|
select request.*
|
||||||
|
into v_request
|
||||||
|
from public.glm_hub_access_requests as request
|
||||||
|
where request.id = p_request_id
|
||||||
|
and request.status = 'pending'
|
||||||
|
for update;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
return jsonb_build_object('ok', false, 'error', 'La solicitud no existe o ya fue atendida.');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from public.glm_hub_access_request_approvers as approver
|
||||||
|
where approver.request_id = p_request_id
|
||||||
|
) then
|
||||||
|
v_token_isaac := encode(extensions.gen_random_bytes(32), 'hex');
|
||||||
|
v_token_jose := encode(extensions.gen_random_bytes(32), 'hex');
|
||||||
|
v_token_maximo := encode(extensions.gen_random_bytes(32), 'hex');
|
||||||
|
|
||||||
|
insert into public.glm_hub_access_request_approvers (
|
||||||
|
request_id,
|
||||||
|
approver_name,
|
||||||
|
approver_email,
|
||||||
|
decision_token,
|
||||||
|
decision_token_hash
|
||||||
|
)
|
||||||
|
values
|
||||||
|
(
|
||||||
|
p_request_id,
|
||||||
|
'Isaac Aracena',
|
||||||
|
'iaracena@gomezleemarketing.com',
|
||||||
|
v_token_isaac,
|
||||||
|
encode(extensions.digest(v_token_isaac, 'sha256'), 'hex')
|
||||||
|
),
|
||||||
|
(
|
||||||
|
p_request_id,
|
||||||
|
'José Leopoldo Gómez',
|
||||||
|
'jgomez@gomezleemarketing.com',
|
||||||
|
v_token_jose,
|
||||||
|
encode(extensions.digest(v_token_jose, 'sha256'), 'hex')
|
||||||
|
),
|
||||||
|
(
|
||||||
|
p_request_id,
|
||||||
|
'Máximo Gómez',
|
||||||
|
'mgomez@gomezleemarketing.com',
|
||||||
|
v_token_maximo,
|
||||||
|
encode(extensions.digest(v_token_maximo, 'sha256'), 'hex')
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'requestId', v_request.id,
|
||||||
|
'requesterName', v_request.requester_name,
|
||||||
|
'requesterEmail', v_request.requester_email,
|
||||||
|
'appName', v_request.app_name,
|
||||||
|
'appCategory', v_request.app_category,
|
||||||
|
'approvers', (
|
||||||
|
select jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'name', approver.approver_name,
|
||||||
|
'email', approver.approver_email,
|
||||||
|
'token', approver.decision_token
|
||||||
|
)
|
||||||
|
order by approver.approver_email
|
||||||
|
)
|
||||||
|
from public.glm_hub_access_request_approvers as approver
|
||||||
|
where approver.request_id = p_request_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
alter function public.glm_hub_issue_access_request_tokens(uuid) owner to postgres;
|
||||||
|
revoke all on function public.glm_hub_issue_access_request_tokens(uuid) from public, anon, authenticated;
|
||||||
|
grant execute on function public.glm_hub_issue_access_request_tokens(uuid) to service_role;
|
||||||
|
|
||||||
|
-- Registra una sola decisión de forma atómica. Aunque los botones sigan visibles
|
||||||
|
-- en correos ya entregados, cualquier segundo clic queda bloqueado en la base de datos.
|
||||||
|
create or replace function public.glm_hub_decide_access_request(
|
||||||
|
p_token text,
|
||||||
|
p_decision text
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
volatile
|
||||||
|
security definer
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_request_id uuid;
|
||||||
|
v_approver_name text;
|
||||||
|
v_approver_email text;
|
||||||
|
v_request public.glm_hub_access_requests%rowtype;
|
||||||
|
v_normalized_decision text;
|
||||||
|
begin
|
||||||
|
v_normalized_decision := lower(btrim(coalesce(p_decision, '')));
|
||||||
|
|
||||||
|
if v_normalized_decision not in ('approved', 'rejected') then
|
||||||
|
return jsonb_build_object('ok', false, 'error', 'La decisión recibida no es válida.');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select approver.request_id, approver.approver_name, approver.approver_email
|
||||||
|
into v_request_id, v_approver_name, v_approver_email
|
||||||
|
from public.glm_hub_access_request_approvers as approver
|
||||||
|
where approver.decision_token_hash = encode(
|
||||||
|
extensions.digest(btrim(coalesce(p_token, '')), 'sha256'),
|
||||||
|
'hex'
|
||||||
|
)
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
return jsonb_build_object('ok', false, 'error', 'El enlace de decisión no es válido o ya no existe.');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.glm_hub_access_requests as request
|
||||||
|
set
|
||||||
|
status = v_normalized_decision,
|
||||||
|
decided_by_name = v_approver_name,
|
||||||
|
decided_by_email = v_approver_email,
|
||||||
|
decided_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
where request.id = v_request_id
|
||||||
|
and request.status = 'pending'
|
||||||
|
returning request.* into v_request;
|
||||||
|
|
||||||
|
if found then
|
||||||
|
update public.glm_hub_access_request_approvers
|
||||||
|
set clicked_at = now()
|
||||||
|
where request_id = v_request_id
|
||||||
|
and approver_email = v_approver_email;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'alreadyDecided', false,
|
||||||
|
'requestId', v_request.id,
|
||||||
|
'status', v_request.status,
|
||||||
|
'requesterName', v_request.requester_name,
|
||||||
|
'requesterEmail', v_request.requester_email,
|
||||||
|
'appName', v_request.app_name,
|
||||||
|
'appCategory', v_request.app_category,
|
||||||
|
'decidedByName', v_request.decided_by_name,
|
||||||
|
'decidedByEmail', v_request.decided_by_email,
|
||||||
|
'decidedAt', v_request.decided_at
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select request.*
|
||||||
|
into v_request
|
||||||
|
from public.glm_hub_access_requests as request
|
||||||
|
where request.id = v_request_id;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', false,
|
||||||
|
'alreadyDecided', true,
|
||||||
|
'status', v_request.status,
|
||||||
|
'requesterName', v_request.requester_name,
|
||||||
|
'requesterEmail', v_request.requester_email,
|
||||||
|
'appName', v_request.app_name,
|
||||||
|
'decidedByName', coalesce(v_request.decided_by_name, ''),
|
||||||
|
'decidedByEmail', coalesce(v_request.decided_by_email, ''),
|
||||||
|
'decidedAt', v_request.decided_at,
|
||||||
|
'error', 'Esta solicitud ya fue atendida y no admite otra decisión.'
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
alter function public.glm_hub_decide_access_request(text, text) owner to postgres;
|
||||||
|
revoke all on function public.glm_hub_decide_access_request(text, text) from public;
|
||||||
|
grant execute on function public.glm_hub_decide_access_request(text, text) to anon;
|
||||||
|
grant execute on function public.glm_hub_decide_access_request(text, text) to authenticated;
|
||||||
|
grant execute on function public.glm_hub_decide_access_request(text, text) to service_role;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- 7. REALTIME DEL CATÁLOGO
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Hace que una aplicación creada/editada/eliminada por un administrador
|
||||||
|
-- aparezca en las sesiones abiertas de los demás usuarios sin recargar.
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if exists (
|
||||||
|
select 1 from pg_publication where pubname = 'supabase_realtime'
|
||||||
|
) and not exists (
|
||||||
|
select 1
|
||||||
|
from pg_publication_tables
|
||||||
|
where pubname = 'supabase_realtime'
|
||||||
|
and schemaname = 'public'
|
||||||
|
and tablename = 'glm_hub_apps'
|
||||||
|
) then
|
||||||
|
execute 'alter publication supabase_realtime add table public.glm_hub_apps';
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
commit;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- OPERACIONES DE ADMINISTRACIÓN (EJECUTAR DESDE SQL EDITOR)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- DAR ACCESO A UN USUARIO NORMAL:
|
||||||
|
-- insert into public.glm_hub_authorized_users (email, full_name, role, is_active)
|
||||||
|
-- values ('usuario@gomezleemarketing.com', 'Nombre del usuario', 'member', true)
|
||||||
|
-- on conflict (email) do update
|
||||||
|
-- set full_name = excluded.full_name, role = 'member', is_active = true;
|
||||||
|
|
||||||
|
-- QUITAR ACCESO SIN BORRAR EL REGISTRO:
|
||||||
|
-- update public.glm_hub_authorized_users
|
||||||
|
-- set is_active = false
|
||||||
|
-- where email = 'usuario@gomezleemarketing.com';
|
||||||
|
|
||||||
|
-- REACTIVAR ACCESO:
|
||||||
|
-- update public.glm_hub_authorized_users
|
||||||
|
-- set is_active = true
|
||||||
|
-- where email = 'usuario@gomezleemarketing.com';
|
||||||
|
|
||||||
|
-- VER SOLICITUDES DE ACCESO:
|
||||||
|
-- select requester_name, requester_email, app_name, status, decided_by_name, decided_by_email, created_at, decided_at
|
||||||
|
-- from public.glm_hub_access_requests
|
||||||
|
-- order by created_at desc;
|
||||||
|
|
||||||
|
-- VER EL CATÁLOGO COMPARTIDO:
|
||||||
|
-- select id, name, category, visibility, url, icon_url, updated_at
|
||||||
|
-- from public.glm_hub_apps
|
||||||
|
-- order by name;
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 310 B |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 768 B |
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
<meta name="description" content="Hub interno de aplicaciones de GomezLee Marketing." />
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260722-v3" />
|
||||||
|
<link rel="shortcut icon" type="image/png" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260722-v3" />
|
||||||
|
<link rel="apple-touch-icon" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260722-v3" />
|
||||||
|
<title>GLM Hub — GomezLee Marketing</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-xOqwTEny.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-BT8YJLeR.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.110.8"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
<meta name="description" content="Hub interno de aplicaciones de GomezLee Marketing." />
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260722-v3" />
|
||||||
|
<link rel="shortcut icon" type="image/png" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260722-v3" />
|
||||||
|
<link rel="apple-touch-icon" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260722-v3" />
|
||||||
|
<title>GLM Hub — GomezLee Marketing</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.110.8"></script>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
{
|
||||||
|
"name": "GLM Hub - Generar ícono con Gemini (Nodo nativo)",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "glm-hub-generar-icono",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {
|
||||||
|
"allowedOrigins": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "47ec047e-250d-4056-a2af-2bb6c5f072b0",
|
||||||
|
"name": "Webhook - Generar ícono",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2.1,
|
||||||
|
"position": [
|
||||||
|
-1120,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "bc20a398-5ecf-42a2-9d17-9396aa278708"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const request = $input.first().json ?? {};\nconst rawBody = request.body ?? request;\nlet body = rawBody;\n\nif (typeof rawBody === 'string') {\n try {\n body = JSON.parse(rawBody);\n } catch {\n body = {};\n }\n}\n\nconst headers = request.headers ?? {};\nconst headerAuthorization = String(headers.authorization ?? headers.Authorization ?? '').trim();\nconst accessToken = String(body.accessToken ?? '').trim();\nconst authorization = headerAuthorization.toLowerCase().startsWith('bearer ')\n ? headerAuthorization\n : accessToken\n ? `Bearer ${accessToken}`\n : '';\nconst name = String(body.name ?? '').trim();\nconst description = String(body.description ?? '').trim();\n\nlet error = '';\nif (!authorization.toLowerCase().startsWith('bearer ')) {\n error = 'La sesión de Supabase no fue enviada o no es válida.';\n} else if (name.length < 2 || name.length > 60) {\n error = 'El nombre debe contener entre 2 y 60 caracteres.';\n} else if (!description || description.length > 180) {\n error = 'La descripción debe contener entre 1 y 180 caracteres.';\n}\n\nreturn [{ json: { valid: !error, error, authorization, name, description } }];"
|
||||||
|
},
|
||||||
|
"id": "934fb8be-c5bc-43bd-b44a-53f9db105b32",
|
||||||
|
"name": "Validar solicitud",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-860,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "308c498d-7c3f-4414-a21f-571276aee28b",
|
||||||
|
"leftValue": "={{ $json.valid }}",
|
||||||
|
"rightValue": true,
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "3344b885-db2b-473d-a444-a51ca00d063d",
|
||||||
|
"name": "¿Solicitud válida?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
-640,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ { ok: false, error: $json.error } }}",
|
||||||
|
"options": {
|
||||||
|
"responseCode": 400,
|
||||||
|
"responseHeaders": {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "Cache-Control",
|
||||||
|
"value": "no-store"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "af555c88-6ec6-4e0f-9d01-64b6cb16a5c5",
|
||||||
|
"name": "Responder 400",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.4,
|
||||||
|
"position": [
|
||||||
|
-400,
|
||||||
|
180
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "={{ ($env.SUPABASE_URL || '').replace(/\\/$/, '') + '/rest/v1/rpc/glm_hub_icon_generation_access' }}",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "apikey",
|
||||||
|
"value": "={{ $env.SUPABASE_ANON_KEY }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{ $('Validar solicitud').item.json.authorization }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "raw",
|
||||||
|
"rawContentType": "application/json",
|
||||||
|
"body": "{}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true,
|
||||||
|
"responseFormat": "json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeout": 30000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "42ba23d6-9f3a-4b67-ae48-8253adfb7110",
|
||||||
|
"name": "Validar administrador en Supabase",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
-400,
|
||||||
|
-80
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const permission = $input.first().json ?? {};\nconst request = $('Validar solicitud').first().json;\nconst authorized = permission.authorized === true;\nreturn [{\n json: {\n authorized,\n error: authorized ? '' : 'Tu sesión no tiene permisos de administrador para generar íconos.',\n name: request.name,\n description: request.description,\n email: String(permission.email ?? '')\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "489c0999-9ca3-4361-8262-18404f5aad35",
|
||||||
|
"name": "Confirmar autorización",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-180,
|
||||||
|
-80
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c9275da4-0bca-4ce9-bd75-cc7d06e4cf56",
|
||||||
|
"leftValue": "={{ $json.authorized }}",
|
||||||
|
"rightValue": true,
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "7c199474-f2b6-4c1c-873d-e578d1f7d500",
|
||||||
|
"name": "¿Es administrador?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
40,
|
||||||
|
-80
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ { ok: false, error: $json.error } }}",
|
||||||
|
"options": {
|
||||||
|
"responseCode": 403,
|
||||||
|
"responseHeaders": {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "Cache-Control",
|
||||||
|
"value": "no-store"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "1b105633-29f8-4e75-bead-d3022420dfed",
|
||||||
|
"name": "Responder 403",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.4,
|
||||||
|
"position": [
|
||||||
|
280,
|
||||||
|
100
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const { name, description } = $input.first().json;\nconst prompt = `Crea un ícono cuadrado 1:1, profesional, minimalista y fácil de reconocer para una aplicación interna de GomezLee Marketing.\n\nNombre de la aplicación: ${name}\nDescripción funcional: ${description}\n\nRequisitos visuales obligatorios:\n- Representa la función principal con un único símbolo simple, claro y reconocible.\n- Estética corporativa moderna, limpia y tecnológica.\n- Paleta inspirada en GLM: azul grisáceo, verde claro y verde azulado.\n- Fondo blanco o transparente.\n- Composición centrada, con buen espacio de respiración.\n- Sin texto, sin letras, sin siglas, sin números, sin marcas comerciales y sin logotipos existentes.\n- Sin mockups, sin fotografías, sin personas, sin sombras exageradas y sin fondos complejos.\n- Debe seguir siendo legible al reducirse a 32 x 32 píxeles.\n- Entrega solamente el ícono final.`;\nreturn [{ json: { prompt } }];"
|
||||||
|
},
|
||||||
|
"id": "1ae24908-c67d-4742-917e-573b69f4ca19",
|
||||||
|
"name": "Construir prompt del ícono",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
280,
|
||||||
|
-180
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"resource": "image",
|
||||||
|
"operation": "generate",
|
||||||
|
"modelId": {
|
||||||
|
"__rl": true,
|
||||||
|
"value": "models/gemini-3-pro-image",
|
||||||
|
"mode": "list",
|
||||||
|
"cachedResultName": "models/gemini-3-pro-image (Nano Banana Pro)"
|
||||||
|
},
|
||||||
|
"prompt": "={{ $json.prompt }}",
|
||||||
|
"options": {
|
||||||
|
"binaryPropertyOutput": "data"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "bdeed7c1-71ba-4af4-9424-b248c06c9e75",
|
||||||
|
"name": "Generate an image",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.googleGemini",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [
|
||||||
|
520,
|
||||||
|
-180
|
||||||
|
],
|
||||||
|
"onError": "continueErrorOutput"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "binary",
|
||||||
|
"responseDataSource": "set",
|
||||||
|
"inputFieldName": "data",
|
||||||
|
"options": {
|
||||||
|
"responseCode": 200,
|
||||||
|
"responseHeaders": {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "Cache-Control",
|
||||||
|
"value": "no-store"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Content-Disposition",
|
||||||
|
"value": "inline; filename=\"glm-hub-icon.png\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "7349fabd-6d25-48b1-94be-64e36eef5f7e",
|
||||||
|
"name": "Responder con ícono",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.4,
|
||||||
|
"position": [
|
||||||
|
800,
|
||||||
|
-260
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const input = $input.first().json ?? {};\nconst rawError = input.error ?? input.message ?? input.description ?? input;\nlet message = 'Gemini no pudo generar el ícono. Revisa la credencial, el modelo y la cuota disponible.';\n\nif (typeof rawError === 'string' && rawError.trim()) {\n message = rawError.trim();\n} else if (rawError && typeof rawError === 'object') {\n message = String(rawError.message ?? rawError.description ?? rawError.status ?? message);\n}\n\nreturn [{ json: { ok: false, error: message } }];"
|
||||||
|
},
|
||||||
|
"id": "fc7b61a0-fb00-42a4-bc4a-2e530da36a12",
|
||||||
|
"name": "Preparar error de Gemini",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
800,
|
||||||
|
-80
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ { ok: false, error: $json.error } }}",
|
||||||
|
"options": {
|
||||||
|
"responseCode": 502,
|
||||||
|
"responseHeaders": {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "Cache-Control",
|
||||||
|
"value": "no-store"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "c0bc7fee-a55e-4f40-90b5-f875c41b131c",
|
||||||
|
"name": "Responder error de Gemini",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.4,
|
||||||
|
"position": [
|
||||||
|
1040,
|
||||||
|
-80
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"content": "## CONFIGURACIÓN ÚNICA\n\nEn el nodo **Generate an image**, confirma la credencial existente **Isaac - Gemini Api Pago** después de importar.\n\nEl webhook devuelve la imagen directamente como archivo binario al GLM Hub.",
|
||||||
|
"height": 220,
|
||||||
|
"width": 380,
|
||||||
|
"color": 5
|
||||||
|
},
|
||||||
|
"id": "fd8a87b4-3924-412b-8d3d-b7707bf6388b",
|
||||||
|
"name": "Nota de configuración",
|
||||||
|
"type": "n8n-nodes-base.stickyNote",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
420,
|
||||||
|
100
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pinData": {},
|
||||||
|
"connections": {
|
||||||
|
"Webhook - Generar ícono": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Validar solicitud",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Validar solicitud": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "¿Solicitud válida?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"¿Solicitud válida?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Validar administrador en Supabase",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Responder 400",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Validar administrador en Supabase": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Confirmar autorización",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Confirmar autorización": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "¿Es administrador?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"¿Es administrador?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Construir prompt del ícono",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Responder 403",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Construir prompt del ícono": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Generate an image",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Generate an image": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Responder con ícono",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Preparar error de Gemini",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Preparar error de Gemini": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Responder error de Gemini",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": false,
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"saveManualExecutions": true,
|
||||||
|
"callerPolicy": "workflowsFromSameOwner"
|
||||||
|
},
|
||||||
|
"versionId": "ae79a50a-79d1-43f8-b856-b4106bae3e60",
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": false
|
||||||
|
},
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "glm-app-hub",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit --pretty false"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "0.575.0",
|
||||||
|
"react": "19.2.7",
|
||||||
|
"react-dom": "19.2.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.2.17",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@vitejs/plugin-react": "5.2.0",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vite": "6.4.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 310 B |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 768 B |
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,98 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import ts from '../node_modules/typescript/lib/typescript.js';
|
||||||
|
|
||||||
|
const root = path.resolve(new URL('..', import.meta.url).pathname);
|
||||||
|
const srcDir = path.join(root, 'src');
|
||||||
|
const distDir = path.join(root, 'dist');
|
||||||
|
const assetsDir = path.join(distDir, 'assets');
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
VITE_SUPABASE_URL: 'https://dbit.digitalcompass.agency',
|
||||||
|
VITE_SUPABASE_ANON_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE',
|
||||||
|
VITE_ICON_GENERATOR_WEBHOOK_URL: 'https://agenteit.digitalcompass.agency/webhook/glm-hub-generar-icono',
|
||||||
|
VITE_ACCESS_REQUEST_WEBHOOK_URL: 'https://agenteit.digitalcompass.agency/webhook/glm-hub-solicitar-acceso',
|
||||||
|
BASE_URL: '/',
|
||||||
|
};
|
||||||
|
|
||||||
|
function replaceEnv(source) {
|
||||||
|
return source
|
||||||
|
.replaceAll('import.meta.env.VITE_SUPABASE_URL', JSON.stringify(env.VITE_SUPABASE_URL))
|
||||||
|
.replaceAll('import.meta.env.VITE_SUPABASE_ANON_KEY', JSON.stringify(env.VITE_SUPABASE_ANON_KEY))
|
||||||
|
.replaceAll('import.meta.env.VITE_ICON_GENERATOR_WEBHOOK_URL', JSON.stringify(env.VITE_ICON_GENERATOR_WEBHOOK_URL))
|
||||||
|
.replaceAll('import.meta.env.VITE_ACCESS_REQUEST_WEBHOOK_URL', JSON.stringify(env.VITE_ACCESS_REQUEST_WEBHOOK_URL))
|
||||||
|
.replaceAll('import.meta.env.BASE_URL', JSON.stringify(env.BASE_URL));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixLocalImports(code) {
|
||||||
|
return code
|
||||||
|
.replace(/^import\s+["']\.\/styles\.css["'];?\s*$/gm, '')
|
||||||
|
.replace(/(from\s+["'])(\.\.?\/[^"']+)(["'])/g, (_m, start, spec, end) => {
|
||||||
|
if (/\.(?:js|mjs|cjs|json|css)$/.test(spec)) return `${start}${spec}${end}`;
|
||||||
|
return `${start}${spec}.js${end}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rmSync(distDir, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(assetsDir, { recursive: true });
|
||||||
|
|
||||||
|
const sourceFiles = ['main.tsx', 'App.tsx', 'data.ts', 'repository.ts', 'storage.ts', 'supabase.ts', 'types.ts'];
|
||||||
|
for (const file of sourceFiles) {
|
||||||
|
const sourcePath = path.join(srcDir, file);
|
||||||
|
const source = replaceEnv(fs.readFileSync(sourcePath, 'utf8'));
|
||||||
|
const result = ts.transpileModule(source, {
|
||||||
|
fileName: file,
|
||||||
|
compilerOptions: {
|
||||||
|
target: ts.ScriptTarget.ES2022,
|
||||||
|
module: ts.ModuleKind.ESNext,
|
||||||
|
jsx: ts.JsxEmit.ReactJSX,
|
||||||
|
importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove,
|
||||||
|
sourceMap: false,
|
||||||
|
},
|
||||||
|
reportDiagnostics: true,
|
||||||
|
});
|
||||||
|
const errors = (result.diagnostics ?? []).filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
|
||||||
|
if (errors.length) {
|
||||||
|
throw new Error(`${file}: ${errors.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')).join('\n')}`);
|
||||||
|
}
|
||||||
|
const outputName = file.replace(/\.tsx?$/, '.js');
|
||||||
|
fs.writeFileSync(path.join(assetsDir, outputName), fixLocalImports(result.outputText));
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.copyFileSync(path.join(srcDir, 'styles.css'), path.join(assetsDir, 'styles.css'));
|
||||||
|
for (const asset of ['glm-logo.png', 'glm-favicon.png', 'glm-tab-icon-16-20260722.png', 'glm-tab-icon-32-20260722.png', 'glm-tab-icon-64-20260722.png', 'glm-tab-icon-180-20260722.png', 'glm-tab-icon-20260722.ico']) {
|
||||||
|
const source = path.join(root, 'public', asset);
|
||||||
|
if (fs.existsSync(source)) fs.copyFileSync(source, path.join(distDir, asset));
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexHtml = `<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
<meta name="description" content="Hub interno de aplicaciones de GomezLee Marketing." />
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260728-access" />
|
||||||
|
<link rel="shortcut icon" type="image/png" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260728-access" />
|
||||||
|
<link rel="apple-touch-icon" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260728-access" />
|
||||||
|
<title>GLM Hub — GomezLee Marketing</title>
|
||||||
|
<link rel="stylesheet" href="/assets/styles.css" />
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"react": "https://cdn.jsdelivr.net/npm/react@19.2.7/+esm",
|
||||||
|
"react/jsx-runtime": "https://cdn.jsdelivr.net/npm/react@19.2.7/jsx-runtime/+esm",
|
||||||
|
"react-dom/client": "https://cdn.jsdelivr.net/npm/react-dom@19.2.7/client/+esm",
|
||||||
|
"lucide-react": "https://cdn.jsdelivr.net/npm/lucide-react@0.575.0/+esm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.110.8"></script>
|
||||||
|
<script type="module" src="/assets/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
fs.writeFileSync(path.join(distDir, 'index.html'), indexHtml);
|
||||||
|
console.log(`Static ESM build created at ${distDir}`);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$projectRoot = [System.IO.Path]::GetFullPath((Split-Path -Parent $PSScriptRoot))
|
||||||
|
Push-Location $projectRoot
|
||||||
|
|
||||||
|
try {
|
||||||
|
& npm run typecheck
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "TypeScript encontró errores."
|
||||||
|
}
|
||||||
|
|
||||||
|
& npm exec vite build
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Vite no pudo compilar la aplicación."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "GLM Hub compilado en $(Join-Path $projectRoot 'dist')"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { extname, join, normalize, resolve } from "node:path";
|
||||||
|
|
||||||
|
const root = resolve(process.cwd(), process.argv[2] ?? "dist");
|
||||||
|
const port = Number(process.env.PORT ?? 4173);
|
||||||
|
const mimeTypes = new Map([
|
||||||
|
[".css", "text/css; charset=utf-8"],
|
||||||
|
[".html", "text/html; charset=utf-8"],
|
||||||
|
[".js", "text/javascript; charset=utf-8"],
|
||||||
|
[".json", "application/json; charset=utf-8"],
|
||||||
|
[".png", "image/png"],
|
||||||
|
[".svg", "image/svg+xml"],
|
||||||
|
[".webp", "image/webp"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
const rawPath = decodeURIComponent((request.url ?? "/").split("?")[0]);
|
||||||
|
const relativePath = normalize(rawPath).replace(/^([/\\])+/, "");
|
||||||
|
let filePath = resolve(join(root, relativePath || "index.html"));
|
||||||
|
|
||||||
|
if (!filePath.startsWith(root)) {
|
||||||
|
response.writeHead(403).end("Forbidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(filePath) || statSync(filePath).isDirectory()) {
|
||||||
|
filePath = join(root, "index.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
response.setHeader("Content-Type", mimeTypes.get(extname(filePath)) ?? "application/octet-stream");
|
||||||
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
|
response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
|
response.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
||||||
|
createReadStream(filePath).pipe(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, "127.0.0.1", () => {
|
||||||
|
console.log(`GLM Hub disponible en http://127.0.0.1:${port}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { HubApp, HubData } from "./types";
|
||||||
|
|
||||||
|
const SEED_DATE = "2026-07-16T12:00:00.000Z";
|
||||||
|
|
||||||
|
export const CATEGORY_ORDER = [
|
||||||
|
"Administración",
|
||||||
|
"Recursos Humanos",
|
||||||
|
"CDC",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SEED_APPS: HubApp[] = [];
|
||||||
|
|
||||||
|
export function createSeedData(): HubData {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
apps: SEED_APPS.map((app) => ({ ...app, keywords: [...app.keywords] })),
|
||||||
|
favoritesByUser: {},
|
||||||
|
recentByUser: {},
|
||||||
|
updatedAt: SEED_DATE,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const GLM_FAVICON_URL =
|
||||||
|
"https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260722-v3";
|
||||||
|
|
||||||
|
function ensureGlmFavicon(): void {
|
||||||
|
document.querySelectorAll<HTMLLinkElement>('link[rel="icon"], link[rel="shortcut icon"]').forEach((link) => {
|
||||||
|
link.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const icon = document.createElement("link");
|
||||||
|
icon.rel = "icon";
|
||||||
|
icon.type = "image/png";
|
||||||
|
icon.sizes = "512x512";
|
||||||
|
icon.href = GLM_FAVICON_URL;
|
||||||
|
document.head.appendChild(icon);
|
||||||
|
|
||||||
|
const shortcut = document.createElement("link");
|
||||||
|
shortcut.rel = "shortcut icon";
|
||||||
|
shortcut.type = "image/png";
|
||||||
|
shortcut.href = GLM_FAVICON_URL;
|
||||||
|
document.head.appendChild(shortcut);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureGlmFavicon();
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { supabase } from "./supabase";
|
||||||
|
import type { AppCategory, AppVisibility, HubApp, HubData, HubUser } from "./types";
|
||||||
|
|
||||||
|
const APPS_TABLE = "glm_hub_apps";
|
||||||
|
const FAVORITES_TABLE = "glm_hub_favorites";
|
||||||
|
const RECENTS_TABLE = "glm_hub_recent_apps";
|
||||||
|
const ICON_BUCKET = "glm-hub-icons";
|
||||||
|
const LOCAL_MIGRATION_MARKER = "glm-hub:supabase-catalog-migrated:v1";
|
||||||
|
|
||||||
|
interface HubAppRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: AppCategory;
|
||||||
|
url: string | null;
|
||||||
|
icon_url: string;
|
||||||
|
visibility: AppVisibility;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveHubAppInput {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: AppCategory;
|
||||||
|
url: string;
|
||||||
|
iconDataUrl: string;
|
||||||
|
visibility: AppVisibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToApp(row: HubAppRow): HubApp {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
category: row.category,
|
||||||
|
keywords: [],
|
||||||
|
url: row.url ?? "",
|
||||||
|
mark: "",
|
||||||
|
accent: "#4F758B",
|
||||||
|
iconDataUrl: row.icon_url,
|
||||||
|
visibility: row.visibility,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataUrlToBlob(dataUrl: string): { blob: Blob; mimeType: string; extension: string } {
|
||||||
|
const match = /^data:(image\/(?:png|jpeg|webp));base64,([a-zA-Z0-9+/=]+)$/.exec(dataUrl);
|
||||||
|
if (!match) throw new Error("El ícono no tiene un formato válido.");
|
||||||
|
|
||||||
|
const mimeType = match[1];
|
||||||
|
const binary = atob(match[2]);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||||
|
|
||||||
|
return {
|
||||||
|
blob: new Blob([bytes], { type: mimeType }),
|
||||||
|
mimeType,
|
||||||
|
extension: mimeType === "image/jpeg" ? "jpg" : mimeType.split("/")[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function storagePathFromPublicUrl(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const marker = `/storage/v1/object/public/${ICON_BUCKET}/`;
|
||||||
|
const markerIndex = parsed.pathname.indexOf(marker);
|
||||||
|
if (markerIndex < 0) return null;
|
||||||
|
return decodeURIComponent(parsed.pathname.slice(markerIndex + marker.length));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadIcon(appId: string, iconDataUrl: string): Promise<string> {
|
||||||
|
if (!iconDataUrl.startsWith("data:image/")) return iconDataUrl;
|
||||||
|
|
||||||
|
const { blob, mimeType, extension } = dataUrlToBlob(iconDataUrl);
|
||||||
|
const path = `apps/${appId}/${Date.now()}-${crypto.randomUUID()}.${extension}`;
|
||||||
|
const { error: uploadError } = await supabase.storage
|
||||||
|
.from(ICON_BUCKET)
|
||||||
|
.upload(path, blob, { cacheControl: "31536000", contentType: mimeType, upsert: false });
|
||||||
|
|
||||||
|
if (uploadError) throw new Error(`No se pudo subir el ícono a Supabase: ${uploadError.message}`);
|
||||||
|
|
||||||
|
const { data } = supabase.storage.from(ICON_BUCKET).getPublicUrl(path);
|
||||||
|
const publicUrl = data?.publicUrl as string | undefined;
|
||||||
|
if (!publicUrl) throw new Error("Supabase no devolvió la URL pública del ícono.");
|
||||||
|
return publicUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeStoredIcon(url: string): Promise<void> {
|
||||||
|
const path = storagePathFromPublicUrl(url);
|
||||||
|
if (!path) return;
|
||||||
|
const { error } = await supabase.storage.from(ICON_BUCKET).remove([path]);
|
||||||
|
if (error) console.warn("No se pudo retirar el ícono anterior de Storage:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchHubApps(): Promise<HubApp[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from(APPS_TABLE)
|
||||||
|
.select("id,name,description,category,url,icon_url,visibility,created_at,updated_at")
|
||||||
|
.order("name", { ascending: true });
|
||||||
|
|
||||||
|
if (error) throw new Error(`No se pudo cargar el catálogo compartido: ${error.message}`);
|
||||||
|
return ((data ?? []) as HubAppRow[]).map(rowToApp);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFavoriteIds(userId: string): Promise<string[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from(FAVORITES_TABLE)
|
||||||
|
.select("app_id,created_at")
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw new Error(`No se pudieron cargar los favoritos: ${error.message}`);
|
||||||
|
return (data ?? []).map((row: { app_id: string }) => row.app_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRecentIds(userId: string): Promise<string[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from(RECENTS_TABLE)
|
||||||
|
.select("app_id,last_opened_at")
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.order("last_opened_at", { ascending: false })
|
||||||
|
.limit(30);
|
||||||
|
|
||||||
|
if (error) throw new Error(`No se pudieron cargar los accesos recientes: ${error.message}`);
|
||||||
|
return (data ?? []).map((row: { app_id: string }) => row.app_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchHubData(userId: string): Promise<HubData> {
|
||||||
|
const [apps, favorites, recents] = await Promise.all([
|
||||||
|
fetchHubApps(),
|
||||||
|
fetchFavoriteIds(userId),
|
||||||
|
fetchRecentIds(userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
apps,
|
||||||
|
favoritesByUser: { [userId]: favorites },
|
||||||
|
recentByUser: { [userId]: recents },
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveHubApp(
|
||||||
|
input: SaveHubAppInput,
|
||||||
|
existing: HubApp | null,
|
||||||
|
userId: string,
|
||||||
|
): Promise<HubApp> {
|
||||||
|
const appId = existing?.id ?? crypto.randomUUID();
|
||||||
|
const iconUrl = await uploadIcon(appId, input.iconDataUrl);
|
||||||
|
const payload = {
|
||||||
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
category: input.category,
|
||||||
|
url: input.url || null,
|
||||||
|
icon_url: iconUrl,
|
||||||
|
visibility: input.visibility,
|
||||||
|
updated_by: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result: { data: unknown; error: { message: string } | null };
|
||||||
|
if (existing) {
|
||||||
|
result = await supabase
|
||||||
|
.from(APPS_TABLE)
|
||||||
|
.update(payload)
|
||||||
|
.eq("id", appId)
|
||||||
|
.select("id,name,description,category,url,icon_url,visibility,created_at,updated_at")
|
||||||
|
.single();
|
||||||
|
} else {
|
||||||
|
result = await supabase
|
||||||
|
.from(APPS_TABLE)
|
||||||
|
.insert({ id: appId, ...payload, created_by: userId })
|
||||||
|
.select("id,name,description,category,url,icon_url,visibility,created_at,updated_at")
|
||||||
|
.single();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
if (iconUrl !== input.iconDataUrl) await removeStoredIcon(iconUrl);
|
||||||
|
throw new Error(`No se pudo guardar la aplicación: ${result.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing && existing.iconDataUrl !== iconUrl) await removeStoredIcon(existing.iconDataUrl);
|
||||||
|
return rowToApp(result.data as HubAppRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHubApp(app: HubApp): Promise<void> {
|
||||||
|
const { error } = await supabase.from(APPS_TABLE).delete().eq("id", app.id);
|
||||||
|
if (error) throw new Error(`No se pudo eliminar la aplicación: ${error.message}`);
|
||||||
|
await removeStoredIcon(app.iconDataUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setFavorite(userId: string, appId: string, favorite: boolean): Promise<void> {
|
||||||
|
if (favorite) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from(FAVORITES_TABLE)
|
||||||
|
.upsert({ user_id: userId, app_id: appId }, { onConflict: "user_id,app_id" });
|
||||||
|
if (error) throw new Error(`No se pudo guardar el favorito: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from(FAVORITES_TABLE)
|
||||||
|
.delete()
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.eq("app_id", appId);
|
||||||
|
if (error) throw new Error(`No se pudo quitar el favorito: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordRecent(userId: string, appId: string): Promise<void> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from(RECENTS_TABLE)
|
||||||
|
.upsert(
|
||||||
|
{ user_id: userId, app_id: appId, last_opened_at: new Date().toISOString() },
|
||||||
|
{ onConflict: "user_id,app_id" },
|
||||||
|
);
|
||||||
|
if (error) throw new Error(`No se pudo registrar el acceso reciente: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrateLocalAppsIfNeeded(user: HubUser, localApps: HubApp[]): Promise<number> {
|
||||||
|
if (user.role !== "admin" || localApps.length === 0) return 0;
|
||||||
|
if (localStorage.getItem(LOCAL_MIGRATION_MARKER) === "completed") return 0;
|
||||||
|
|
||||||
|
const remoteApps = await fetchHubApps();
|
||||||
|
const remoteNames = new Set(remoteApps.map((app) => app.name.trim().toLocaleLowerCase("es-DO")));
|
||||||
|
const missingApps = localApps.filter((app) => {
|
||||||
|
const normalizedName = app.name.trim().toLocaleLowerCase("es-DO");
|
||||||
|
return Boolean(app.iconDataUrl.trim()) && !remoteNames.has(normalizedName);
|
||||||
|
});
|
||||||
|
|
||||||
|
let migratedCount = 0;
|
||||||
|
for (const app of missingApps) {
|
||||||
|
await saveHubApp(
|
||||||
|
{
|
||||||
|
name: app.name,
|
||||||
|
description: app.description,
|
||||||
|
category: app.category,
|
||||||
|
url: app.url,
|
||||||
|
iconDataUrl: app.iconDataUrl,
|
||||||
|
visibility: app.visibility,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
remoteNames.add(app.name.trim().toLocaleLowerCase("es-DO"));
|
||||||
|
migratedCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(LOCAL_MIGRATION_MARKER, "completed");
|
||||||
|
return migratedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeToHubApps(onChange: () => void): () => void {
|
||||||
|
const channel = supabase
|
||||||
|
.channel("glm-hub-apps-shared")
|
||||||
|
.on(
|
||||||
|
"postgres_changes",
|
||||||
|
{ event: "*", schema: "public", table: APPS_TABLE },
|
||||||
|
() => onChange(),
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
void supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { createSeedData } from "./data";
|
||||||
|
import type { HubApp, HubData } from "./types";
|
||||||
|
|
||||||
|
const DATA_KEY = "glm-hub:state:v1";
|
||||||
|
|
||||||
|
export interface LoadDataResult {
|
||||||
|
data: HubData;
|
||||||
|
warning: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHubApp(value: unknown): value is HubApp {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const app = value as Partial<HubApp>;
|
||||||
|
return (
|
||||||
|
typeof app.id === "string" &&
|
||||||
|
typeof app.name === "string" &&
|
||||||
|
typeof app.description === "string" &&
|
||||||
|
typeof app.category === "string" &&
|
||||||
|
Array.isArray(app.keywords) &&
|
||||||
|
typeof app.url === "string" &&
|
||||||
|
typeof app.mark === "string" &&
|
||||||
|
typeof app.accent === "string" &&
|
||||||
|
typeof app.iconDataUrl === "string" &&
|
||||||
|
(app.visibility === "published" || app.visibility === "hidden")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHubData(value: unknown): value is HubData {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const data = value as Partial<HubData>;
|
||||||
|
return (
|
||||||
|
data.schemaVersion === 1 &&
|
||||||
|
Array.isArray(data.apps) &&
|
||||||
|
data.apps.every(isHubApp) &&
|
||||||
|
!!data.favoritesByUser &&
|
||||||
|
typeof data.favoritesByUser === "object" &&
|
||||||
|
!!data.recentByUser &&
|
||||||
|
typeof data.recentByUser === "object"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepExistingAppIds(groups: Record<string, string[]>, appIds: Set<string>): Record<string, string[]> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(groups).map(([userId, ids]) => [userId, ids.filter((id) => appIds.has(id))]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateCategory(category: string): HubApp["category"] {
|
||||||
|
switch (category) {
|
||||||
|
case "Administración":
|
||||||
|
case "Recursos Humanos":
|
||||||
|
case "CDC":
|
||||||
|
return category;
|
||||||
|
case "Recursos":
|
||||||
|
return "Recursos Humanos";
|
||||||
|
case "Clientes":
|
||||||
|
case "Creatividad":
|
||||||
|
return "CDC";
|
||||||
|
case "Operaciones":
|
||||||
|
case "Analítica":
|
||||||
|
default:
|
||||||
|
return "Administración";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateAppCategories(data: HubData): HubData {
|
||||||
|
let changed = false;
|
||||||
|
const apps = data.apps.map((app) => {
|
||||||
|
const category = migrateCategory(app.category);
|
||||||
|
if (category === app.category) return app;
|
||||||
|
changed = true;
|
||||||
|
return { ...app, category };
|
||||||
|
});
|
||||||
|
|
||||||
|
return changed
|
||||||
|
? { ...data, apps, updatedAt: new Date().toISOString() }
|
||||||
|
: data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAppsWithoutLogo(data: HubData): HubData {
|
||||||
|
const apps = data.apps.filter((app) => Boolean(app.iconDataUrl.trim()));
|
||||||
|
if (apps.length === data.apps.length) return data;
|
||||||
|
|
||||||
|
const appIds = new Set(apps.map((app) => app.id));
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
apps,
|
||||||
|
favoritesByUser: keepExistingAppIds(data.favoritesByUser, appIds),
|
||||||
|
recentByUser: keepExistingAppIds(data.recentByUser, appIds),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadHubData(): LoadDataResult {
|
||||||
|
const fallback = createSeedData();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(DATA_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
localStorage.setItem(DATA_KEY, JSON.stringify(fallback));
|
||||||
|
return { data: fallback, warning: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!isHubData(parsed)) {
|
||||||
|
localStorage.setItem(`${DATA_KEY}:backup`, raw);
|
||||||
|
localStorage.setItem(DATA_KEY, JSON.stringify(fallback));
|
||||||
|
return {
|
||||||
|
data: fallback,
|
||||||
|
warning: "Los datos locales no eran válidos. Restauramos el catálogo inicial.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrated = migrateAppCategories(parsed);
|
||||||
|
const cleaned = removeAppsWithoutLogo(migrated);
|
||||||
|
if (cleaned !== parsed) {
|
||||||
|
localStorage.setItem(DATA_KEY, JSON.stringify(cleaned));
|
||||||
|
}
|
||||||
|
return { data: cleaned, warning: "" };
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
data: fallback,
|
||||||
|
warning: "No pudimos leer los datos guardados. Estás viendo el catálogo inicial.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveHubData(data: HubData): { ok: boolean; error: string } {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DATA_KEY, JSON.stringify(data));
|
||||||
|
return { ok: true, error: "" };
|
||||||
|
} catch (error) {
|
||||||
|
const isQuota =
|
||||||
|
error instanceof DOMException &&
|
||||||
|
(error.name === "QuotaExceededError" || error.name === "NS_ERROR_DOM_QUOTA_REACHED");
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: isQuota
|
||||||
|
? "El almacenamiento está lleno. Prueba con un logo más liviano."
|
||||||
|
: "No pudimos guardar los cambios en este navegador.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetHubData(): HubData {
|
||||||
|
const next = createSeedData();
|
||||||
|
localStorage.setItem(DATA_KEY, JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSearch(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toLocaleLowerCase("es-DO")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSafeUrl(value: string): string | null {
|
||||||
|
if (!value.trim()) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(value.trim());
|
||||||
|
const isLocalHttp =
|
||||||
|
url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
|
||||||
|
if (url.protocol !== "https:" && !isLocalHttp) return null;
|
||||||
|
if (url.username || url.password) return null;
|
||||||
|
return url.toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
type SupabaseBrowserLibrary = {
|
||||||
|
createClient: (url: string, key: string, options?: unknown) => any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL?.trim();
|
||||||
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY?.trim();
|
||||||
|
const browserLibrary = (globalThis as typeof globalThis & { supabase?: SupabaseBrowserLibrary }).supabase;
|
||||||
|
|
||||||
|
if (!supabaseUrl || !supabaseAnonKey) {
|
||||||
|
throw new Error("Faltan VITE_SUPABASE_URL o VITE_SUPABASE_ANON_KEY en el archivo .env.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!browserLibrary) {
|
||||||
|
throw new Error("No se pudo cargar la librería de Supabase.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supabase = browserLibrary.createClient(supabaseUrl, supabaseAnonKey, {
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: true,
|
||||||
|
persistSession: true,
|
||||||
|
detectSessionInUrl: true,
|
||||||
|
flowType: "pkce",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export type UserRole = "admin" | "member";
|
||||||
|
|
||||||
|
export type AppCategory =
|
||||||
|
| "Administración"
|
||||||
|
| "Recursos Humanos"
|
||||||
|
| "CDC";
|
||||||
|
|
||||||
|
export type AppVisibility = "published" | "hidden";
|
||||||
|
|
||||||
|
export interface HubApp {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: AppCategory;
|
||||||
|
keywords: string[];
|
||||||
|
url: string;
|
||||||
|
mark: string;
|
||||||
|
accent: string;
|
||||||
|
iconDataUrl: string;
|
||||||
|
visibility: AppVisibility;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HubUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: UserRole;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
user: HubUser;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HubData {
|
||||||
|
schemaVersion: 1;
|
||||||
|
apps: HubApp[];
|
||||||
|
favoritesByUser: Record<string, string[]>;
|
||||||
|
recentByUser: Record<string, string[]>;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastMessage {
|
||||||
|
id: string;
|
||||||
|
kind: "success" | "info" | "error";
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_SUPABASE_URL: string;
|
||||||
|
readonly VITE_SUPABASE_ANON_KEY: string;
|
||||||
|
readonly VITE_ICON_GENERATOR_WEBHOOK_URL: string;
|
||||||
|
readonly VITE_ACCESS_REQUEST_WEBHOOK_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
// base: "/glm-hub/",
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 4173,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 4173,
|
||||||
|
},
|
||||||
|
});
|
||||||