Radiografía del formulario de registro de eventos (FE), su conexión con la base de datos, el panel de aprobación y el directorio de organizadores. Escrita contra el código real en producción y la base de datos viva, no contra el documento de especificación. Objetivo: dar el panorama completo para iniciar un proceso de scraping/ingesta de eventos.
Panorama y arquitectura del SGE
Cómo está montado el módulo de eventos de punta a punta.
El corazón del módulo es src/lib/components/event/EventFormShell.svelte (2.908 líneas): un Formulario Dinámico de Eventos (FE / EventFormShell) de 4 pasos, montado por dos wrappers idénticos que solo cambian el context:
/registro-evento → context="public" registro-evento/+page.svelte:55/dashboard/registro-evento → context="dashboard"; hace POST absoluto a /registro-evento?/submit dashboard/registro-evento/+page.svelte:31Los server actions (submit y save) viven únicamente en src/routes/registro-evento/+page.server.ts y usan un cliente service-role (SUPABASE_ROLE_KEY) que bypasea RLS para el insert +page.server.ts:19. Esto importa para el scraping: la lectura pública está restringida por RLS a status='APPROVED', así que cualquier ingesta masiva debe ir con service-role.
eventos tiene 2.514 filas, casi todas APPROVED (2.507, histórico importado) + 7 DRAFT. La cola de curaduría (PENDING/CAMBIOS/REJECTED/INACTIVO) está en cero. Solo 2 personas administran eventos hoy: 1 super_admin global + 1 admin de módulo.Formulario FE — los 4 pasos, campo por campo
Pasos declarados en EventFormShell.svelte:176. Rojo = obligatorio.
| Campo técnico | Etiqueta en pantalla | Tipo | Oblig. | Validación | Ref. |
|---|---|---|---|---|---|
| contactEmail | Correo electrónico | Sí | no vacío + regex /^[^\s@]+@[^\s@]+\.[^\s@]+$/ | tpl 1096; val 181 | |
| password | Contraseña | password | Sí* | *solo público sin sesión. Si el email existe → verifica contra Supabase Auth; si es nuevo → 5 criterios (mín 8, mayúscula, minúscula, número, especial) | tpl 1160; val 193-198 |
| passwordConfirm | Confirmar contraseña | password | Sí* | *solo email nuevo. Debe coincidir | tpl 1270; val 199 |
| termsAccepted | Acepto los Términos y condiciones | checkbox | Sí* | *solo email nuevo. Marcado | tpl 1316; val 201 |
| privacyAccepted | Política de Tratamiento de Datos | checkbox | Sí* | *solo email nuevo. Marcado | tpl 1324; val 202 |
| contactFirstName | Nombres | text | Sí | no vacío | tpl 1334; val 182 |
| contactLastName | Apellidos | text | Sí | no vacío | tpl 1363; val 183 |
| contactPhone | Teléfono | PhoneInput (prefijo país + número) | Sí | no vacío | tpl 1391; val 184 |
Con sesión activa el email queda readonly con badge "Sesión activa". Con perfil verificado (público, contraseña OK), Nombres/Apellidos/Teléfono se autollenan y bloquean (lockedProfile).
| Campo técnico | Etiqueta | Tipo | Oblig. | Notas | Ref. |
|---|---|---|---|---|---|
| eventoNombre | Nombre del evento | text | Sí | — | tpl 1451; val 210 |
| superEventoId | Asociar a Super Evento | select (SuperEventoPicker) | No | desde tabla super_eventos vigentes | tpl 1469 |
| modalidad | Modalidad del evento | radio | Sí | presencial / online / híbrido / dual | tpl 1483; val 211 |
| organizadores | Organizadores | OrganizerRows (nombre typeahead + rol) | Sí | ≥1 con nombre; rol obligatorio si hay nombre | tpl 1513; val 216 |
| fechaInicio | Fecha de inicio | text dd-mmm-aaaa + date picker | Sí | — | tpl 1535; val 212 |
| fechaFin / useSameDate | Fecha de finalización / "Usar misma fecha" | text + checkbox | Sí¹ | ¹salvo useSameDate; debe ser ≥ inicio | tpl 1579; val 213 |
| zonaHoraria | Zona horaria | select (TimezonePicker) | Sí | si online aparece aquí; si presencial/híbrido/dual aparece en el subpanel de ubicación | tpl 1636 |
| horarios | Horarios por día | ScheduleRows (una fila por día) | Sí | hora_inicio + hora_fin por día; fin > inicio | tpl 1653; val 215 |
| linkSesion | Link al evento | url | No | solo online/híbrido/dual; si presente debe ser URL | tpl 1668; val 227 |
Subpanel de ubicación (solo si presencial/híbrido/dual):
| Campo técnico | Etiqueta | Tipo | Oblig. | Notas | Ref. |
|---|---|---|---|---|---|
| ubicacionTexto | Dirección o nombre del lugar | LocationAutocomplete (Google Places) | No | — | tpl 1700 |
| eventoPais | País | RegionCountryPicker (single) | Sí | — | tpl 1780; val 219 |
| eventoCiudad | Ciudad | text (autollenado por Places, editable) | Sí | — | tpl 1806; val 220 |
| eventoEstado | Provincia / Departamento | text | Sí² | ²condicionado: aparece solo si Ciudad tiene valor | tpl 1827; val 221 |
| Campo técnico | Etiqueta | Tipo | Oblig. | Notas | Ref. |
|---|---|---|---|---|---|
| tematicas | Temática central del evento | EventThemePicker (multiselect categorizado) | Sí | ≥1 | tpl 1886; val 234 |
| actividades | Actividades a desarrollar | EventActivityPicker (multiselect) | Sí | ≥1 | tpl 1928; val 235 |
| hostsSpeakers / speakersMode | Speakers y/o Hosts | HostSpeakerRows (3 modos) | Sí | modo "registrar" exige ≥1 completo; modos "confirmar" / "no-aplica" guardan centinela | tpl 1952; val 236 |
| Campo técnico | Etiqueta | Tipo | Oblig. | Notas | Ref. |
|---|---|---|---|---|---|
| costoInscripcion | Costo de inscripción | radio | Sí | 4 valores (ver §4) | tpl 1998; val 271 |
| linkRegistro | Link de registro de asistentes | url | Sí | URL válida | tpl 2024; val 272 |
| linkEvento | Link al organizador / al evento | url | No | no puede ser igual a linkRegistro | tpl 2047; val 275 |
| masInformacion | Más información del evento | textarea (máx 500) | No | — | tpl 2068 |
handleSubmit corre los 4 validadores, agrupa errores por panel y salta al primer panel con error. Antes del insert real muestra un modal de resumen (EventSummaryModal) que el usuario confirma. script 675-809Origen de cada lista: catálogo vs código
Qué selects leen de una tabla Supabase y cuáles tienen la lista hardcodeada en el código.
| Lista / select | Fuente | Detalle (tabla o archivo) |
|---|---|---|
| Modalidad | Código | src/lib/data/eventModalities.ts |
| Costo de inscripción | Código | src/lib/data/eventCostOptions.ts |
| Temáticas (no sectoriales) | Código espejo | src/lib/data/eventThemes.ts — espejo de tabla catalogo_tematica_evento vía scripts/regen-event-catalogs.py |
| Temáticas (sectorial) | Código derivado | deriva de industriesTech.ts + industriesTraditional.ts (espejo de catalogo_industria) |
| Actividades | Código espejo | src/lib/data/eventActivities.ts — espejo de catalogo_actividad_evento |
| Zona horaria | Código | src/lib/data/timezones.ts (TIMEZONES) |
| País (selector del form) | Código | src/lib/data/countries.ts vía RegionCountryPicker — NO lee de tabla |
| Roles de organizador | Tabla | catalogo_rol_ecosistema (getEcosystemRolesGrouped, taxonomies.ts:121) |
| Supereventos | Tabla | super_eventos (filtrados por año de finalización ≥ actual) |
| Organizador (nombre) typeahead | Tabla vía API | GET /api/entidades/search — Startup/PyME/ICG + directorio |
| Perfil de speaker | Código | PERFIL_OPTIONS en src/lib/utils/speakerValidation.ts:18 |
| Ciudad / lugar | Externo | Google Places API (LocationAutocomplete.svelte) |
catalogo_pais SÍ existe y se usa en otros lugares (endpoint /api/country-iso2, getCountriesCatalog), pero el selector de país del formulario usa la lista hardcodeada de countries.ts, no la tabla. Los catálogos de temáticas/actividades son espejos regenerados de tablas: si se cambia la tabla en DB sin correr regen-event-catalogs.py, el form y el generador de código quedan desalineados.Listas cerradas y su valor almacenado
No la etiqueta visible, sino el valor que queda en la base de datos.
eventos.modalidad| Valor almacenado | Etiqueta | En prod |
|---|---|---|
| presencial | Presencial | 260 |
| online | Online | 645 |
| hibrido | Híbrido | 93 |
| dual | Dual | 2 |
CHECK en DB: modalidad IN ('presencial','online','hibrido','dual').
eventos.costo_inscripcion| Valor almacenado | Etiqueta | En prod |
|---|---|---|
| ENTRADA_LIBRE_SIN_REGISTRO | Entrada libre -NO requiere registro previo- | 7 |
| ENTRADA_LIBRE_CON_REGISTRO | Entrada libre -Requiere registro previo- | 551 |
| POR_INVITACION | Por invitación -Requiere registro y aprobación- | 242 |
| SI | Sí -Tiene costo- | 200 |
eventos.tematicas (JSONB [{categoria, subcategoria, principal?}])Se almacena el id de categoría y subcategoría. Categorías no sectoriales y sus subcategorías (eventThemes.ts):
| Categoría | Subcategorías (id almacenado) |
|---|---|
| emprendimiento | emprendimiento, modelos-de-negocio |
| innovacion | tecnologia, inteligencia-artificial, gestion-innovacion |
| inversion | aceleracion, inversion-de-riesgo, levantamiento-de-capital |
| economia | contexto-economico, negocios-internacionales |
| estrategia-gestion | estrategia, finanzas, gestion, legal, operaciones, rrhh, sostenibilidad |
| marketing-ventas | ecommerce, marketing, ventas |
| desarrollo-profesional | habilidades-blandas, liderazgo, networking |
| otros-temas | educacion, otra |
| sectorial | >130 ids derivados de sub-industrias Tech + Tradicional (ej. fintech-financial, healthtech, agrotech, retail, banca…) — lista completa en eventThemeCodes.ts:44-186 |
eventos.actividades (JSONB [{categoria, subcategoria}])| Categoría | Subcategorías (id) |
|---|---|
| academica | capacitacion, clase, conferencia, conversatorio, curso, debate, foro, masterclass, taller, webinar, workshop |
| networking | actividad-bienestar, actividad-deportiva, after-party, almuerzo, cena, coctel, desayuno, networking-session |
| matchmaking | demo-day, muestra-comercial, pitch-practice, reverse-pitch, rueda-de-negocios, sesion-informativa |
| tech-weeks | tech-week |
| otro | otro |
hosts_speakers[].perfilSpeaker · Keynote Speaker / Orador central · Moderador · Panelista · Host. Centinelas (modo no-registrar): "No Aplica", "Por confirmar".
eventos.zona_horaria (IANA)Lista IANA (Bogotá, Lima, Guayaquil, Caracas, La Paz, Santiago, Buenos Aires, São Paulo, Montevideo, Asunción, México, Madrid…). Default America/Bogota. Reales top: America/Bogota 750, America/Mexico_City 80, America/Argentina/Buenos_Aires 39, America/Guayaquil 35.
eventos.pais_codepais_code guarda el nombre en español del país ("Colombia", "Argentina", "Brasil"), no un código ISO. Además el histórico trae nombres en inglés ("Brazil" 41 filas vs "Brasil" 19). En eventos online queda vacío. Reales: Argentina 100, Colombia 92, Chile 76, Brazil 41, Brasil 19, + 665 vacíos.Validaciones, condicionados y reglas de dependencia
Qué se muestra, se oculta o se exige según otros campos.
presencial | hibrido | dual. Controla todo el subpanel de ubicación (país/ciudad/provincia/dirección) y la posición del selector de zona horaria. Si online: se oculta ubicación y país/ciudad/provincia no se exigen.{#if eventoCiudad.trim()}).modalidad !== 'presencial': el "Link al evento" (linkSesion) aparece para online/híbrido/dual.countryMismatch, val 223). La discrepancia de zona horaria es aviso no bloqueante con botón "Usar la sugerida".{nombres:'No Aplica'}; "confirmar" guarda {nombres:'Por confirmar'}; la validación de "completo" trata esos centinelas como vacío.linkRegistro y linkSesion validan /^https?:\/\/\S+\.\S+/; linkEvento no puede ser igual a linkRegistro.Fecha, hora, zona horaria y modalidad
Cómo se capturan y almacenan, y cómo cambia el form según la modalidad.
dd-mmm-aaaa (ej. 15-jun-2026) parseada a ISO yyyy-mm-dd. Se almacenan fecha_inicio y fecha_finalizacion como DATE (CHECK fecha_finalizacion >= fecha_inicio).horarios JSONB, una fila por día del rango: [{fecha:"yyyy-mm-dd", hora_inicio:"HH:MM", hora_fin:"HH:MM"}]. Duración default por modalidad: presencial/híbrido/dual 120 min, online 60 min. Máx 30 días (se trunca).zona_horaria TEXT, default America/Bogota. Existe además zona_horaria_remota para el público remoto en híbrido, pero el form no la captura → siempre NULL.modalidad): online → sin ubicación, zona horaria arriba, con link de sesión. presencial → con ubicación, sin link de sesión. híbrido/dual → con ubicación y link de sesión.Ubicación y Google Maps
Integración con Google Places y qué se persiste (y qué no).
LocationAutocomplete.svelte usa @googlemaps/js-api-loader (client-side, PUBLIC_GOOGLE_PLACES_API_KEY): monta google.maps.places.Autocomplete + mapa embebido con marker. El autocomplete se restringe al país seleccionado usando el ISO-2 resuelto por GET /api/country-iso2?country= (ese endpoint sí lee catalogo_pais). Al elegir un lugar autocompleta país, provincia, ciudad, dirección (ubicacion_texto) y aplica la zona horaria por país.
lat/lng, el server action NO persiste ubicacion_lat/ubicacion_lng (no están en el payload del insert). En la muestra real ambos vienen null en todo el flujo público.Superevento (relación padre-hijo)
Agrupación de eventos bajo un paraguas (ej. una "Tech Week").
Tabla super_eventos con columnas id, nombre, mnemotecnia (3-5 chars), pais_code (nombre), anio_inicio, anio_finalizacion, link_url, codigo_unico (serial), fecha_inicio, fecha_finalizacion. Ejemplo real: "Colombia Tech Week 2026", mnemotecnia CTW. La FK es eventos.super_evento_id → super_eventos(id).
La asignación en el form es un SuperEventoPicker (select simple, opcional); el form no permite crear supereventos (redirige a comunidad@lpdi.co). Si un evento se asocia, su evento_codigo recibe el sufijo -MNEMOTECNIA-AA (ej. …-CTW-26).
Organizadores en el formulario
Con entidad registrada vs organizador "raw" (sin entidad).
Componente OrganizerRows.svelte. Cada fila es {nombre, rol, entityType?, entityId?}:
GET /api/entidades/search devuelve entidades Startup/PyME/Scaleup/ICG. Al elegirla se fija entityId+entityType y el rol se autollena y bloquea.POST /api/eventos/invitar-organizador.eventos.organizadores_raw (JSONB) tras sanitizar a la whitelist {nombre, rol, entityType, entityId} (descarta filas sin nombre). Ejemplo real: [{"rol":"Startup","nombre":"La Punta del Iceberg","entityId":"…","entityType":"STARTUP"},{"rol":"Limited Partner","nombre":"Frank Prieto"}].syncOrganizadoresFromEvento crea/vincula filas en directorio_organizadores + evento_organizadores (best-effort, nunca bloquea el insert).Códigos del evento (Sistema de Codificación, SC)
Son DOS columnas distintas — fácil de confundir.
evento_codigo (TEXT) — la cadena legiblePatrón M-PP-TTT-AA-NNNNN, generado server-side en el submit final:
| Segmento | Qué es | Regla |
|---|---|---|
| M | Modalidad | presencial→P, online→V, hibrido→H, dual→D |
| PP | País | ISO alpha-2 (2 letras) vía getIsoAlpha2(pais); online → 00 |
| TTT | Temática principal | 3 dígitos (mapa de 161 códigos en eventThemeCodes.ts); fallback 999 |
| AA | Año | 2 dígitos del año de fecha_inicio |
| NNNNN | Consecutivo | correlativo anual zero-padded (RPC next_evento_consecutivo, tabla evento_codigo_counters) |
Sufijo opcional -MNEMOTECNIA-AA si hay superevento. Ejemplo real verificado: H-CO-008-26-00025 (Híbrido, Colombia, temática contexto-económico, 2026, consecutivo 25).
codigo_unico (entero) — correlativo internoUn entero (ej. 245), no la cadena. Se asigna solo en el submit final (nunca en drafts) vía la RPC idempotente assign_evento_codigo_unico (hace nextval solo si es NULL). Los DRAFT quedan con codigo_unico = NULL. Ambos códigos se generan 100% en el servidor; unicidad garantizada por índice único parcial (evento_codigo) y UNIQUE (codigo_unico).
evento_codigo usa alpha-2 (pese a un comentario viejo que dice M-PPP-TTT), mientras que el código de super_eventos usa el prefijo telefónico de 3 dígitos. Dos criterios distintos.Endpoint de envío y payload exacto
Qué recibe el servidor y con qué forma.
Envío: POST /registro-evento?/submit (SvelteKit form action, header x-sveltekit-action: true). Borrador/autosave: POST /registro-evento?/save (guarda DRAFT sin código). El payload es FormData con estos campos:
| name | contenido |
|---|---|
| contactEmail | email (lowercased) |
| contactFirstName / contactLastName / contactPhone | strings |
| context | 'public' | 'dashboard' |
| password / emailCheckStatus | solo si requiere validar contraseña |
| termsAccepted / privacyAccepted | solo si el email es nuevo |
| isLoggedIn | 'true' | 'false' |
| eventoNombre | string |
| superEventoId | uuid o '' |
| modalidad | presencial/online/hibrido/dual |
| organizadoresRaw | JSON [{nombre,rol,entityType?,entityId?}] |
| fechaInicio / fechaFin | yyyy-mm-dd |
| zonaHoraria | IANA |
| horarios | JSON [{fecha,hora_inicio,hora_fin}] |
| ubicacionTexto / eventoPais / eventoEstado / eventoCiudad | strings |
| tematicas | JSON [{categoria,subcategoria,principal?}] |
| actividades | JSON [{categoria,subcategoria}] |
| hostsSpeakers | JSON [{email,nombres,apellidos,perfil,linkedin}] |
| speakersMode | registrar/confirmar/no-aplica |
| costoInscripcion | uno de los 4 valores |
| linkRegistro / linkEvento / linkSesion / masInformacion | strings |
| eventoId | uuid del draft (si existe) |
ubicacionSoloRegistrados / linkSoloRegistrados (el shell no los mete → siempre false), ubicacion_lat/lng, zona_horaria_remota, contact_phone_country_code.Esquema real de la tabla eventos
Base: migración 049_eventos_schema.sql + parches. Columnas, tipos y orígenes.
| Columna | Tipo | Notas |
|---|---|---|
| id | UUID PK | gen_random_uuid() |
| registered_by | UUID FK→auth.users | usuario creador |
| contact_email | TEXT NOT NULL | — |
| contact_first_name / contact_last_name | TEXT | — |
| empresa_registro | TEXT | del perfil, no del form |
| contact_phone / contact_phone_country_code | TEXT | country_code no lo puebla el form |
| nombre | TEXT NOT NULL | — |
| super_evento_id | UUID FK→super_eventos | — |
| modalidad | TEXT NOT NULL | CHECK (4 valores) |
| fecha_inicio / fecha_finalizacion | DATE NOT NULL | CHECK fin ≥ inicio |
| horarios | JSONB | una fila por día |
| zona_horaria | TEXT NOT NULL | default America/Bogota |
| zona_horaria_remota | TEXT | no lo puebla el form |
| ubicacion_texto | TEXT | dirección / nombre del lugar |
| ubicacion_lat / ubicacion_lng | NUMERIC(10,7) | no se persisten desde el form (NULL) |
| pais_code | TEXT | guarda el nombre, no ISO |
| estado_provincia / ciudad | TEXT | — |
| tematicas / actividades / hosts_speakers | JSONB | — |
| costo_inscripcion | TEXT NOT NULL | CHECK (4 valores) |
| link_registro / link_evento / link_sesion | TEXT | — |
| mas_informacion | TEXT | CHECK ≤ 500 |
| codigo_unico | int UNIQUE | era SERIAL; mig 134 quitó DEFAULT/NOT NULL |
| evento_codigo | TEXT | único parcial uq_eventos_evento_codigo |
| ubicacion_solo_registrados / link_acceso_solo_registrados | BOOL default false | hoy siempre false (form no los manda) |
| status | TEXT NOT NULL default 'DRAFT' | CHECK: DRAFT, PENDING, APPROVED, REJECTED, CAMBIOS, INACTIVO |
| feedback | TEXT | comentario de curaduría |
| approved_at / approved_by | TZ / UUID | — |
| rejected_at / inactivated_at | TZ | — |
| edited_at / edited_by | TZ / UUID | último editor (se sobrescribe) |
| deleted_at / deleted_by / delete_scheduled_for | TZ / UUID / TZ | soft-delete + purga +30d |
| publish_at | TZ | publicación programada |
| responsable_id | UUID | — |
| es_prueba | BOOL default false | datos sembrados (prod = 0) |
| organizadores_raw | JSONB | organizadores del form |
| created_at / updated_at | TZ | trigger set_eventos_updated_at |
Índices: PK id; UNIQUE codigo_unico; único parcial uq_eventos_evento_codigo; btree en fecha_finalizacion, fecha_inicio, modalidad, status, pais_code, registered_by y parciales en deleted_at, es_prueba, publish_at; GIN en tematicas, actividades y trigram en nombre. RLS: lectura pública solo status='APPROVED' o registered_by = auth.uid().
Tablas relacionadas: super_eventos, directorio_organizadores, evento_organizadores (N:M, PK (evento_id, organizador_id)), evento_favoritos (PK (user_id, evento_id)), event_editor_privileges (vacía hoy), event_autopublish_privileges (email citext PK, vacía hoy).
Campos derivados/calculados en el server
No vienen del formulario; los pone el +page.server.ts.
evento_codigo y codigo_unico — compuestos/asignados en el server (§10), solo en el submit final.status — 'APPROVED' si el correo tiene privilegio de autopublish (RPC is_event_autopublish), si no 'PENDING'. El borrador es 'DRAFT'.approved_at / approved_by — solo si autopublish.empresa_registro — resuelto del perfil existente, no del form.registered_by — usuario resuelto de la sesión o del email (§14).created_at / updated_at — timestamp + trigger.ubicacion_solo_registrados / link_acceso_solo_registrados — forzados a false si la entrada es libre; hoy siempre false.nombre — fallback "[BORRADOR] Evento de …" si viene vacío.publicacion_exitosa / registro_exitoso + nuevo_por_aprobar / actualizado / cambios_por_aprobar).Conexión con usuarios y entidades
Cómo se enlaza el evento al usuario que lo crea y a las entidades del ecosistema.
Resolución de usuario en el submit: (1) si hay sesión → safeGetSession(); (2) si no, busca el perfil por contact_email en profiles (y valida la contraseña con signInWithPassword si es público); (3) si no existe (público) → crea la cuenta con createEcosystemUser (Opción B, con ciclo de confirmación). Ese usuario queda como registered_by.
Perfil: se crea si falta; si existe, solo se completa (fill-only, nunca sobreescribe). Entidades (Startup/PyME/ICG): se enlazan a nivel organizador, no a nivel evento — el vínculo va en organizadores_raw[].entityId/entityType y el dual-write puebla directorio_organizadores + evento_organizadores. No hay FK directa evento→entidad; empresa_registro es solo el texto de la empresa del perfil del registrante.
Endpoints de apoyo: POST /api/check-email, POST /api/auth/verify-event-user, GET /api/entidades/search, GET /api/country-iso2, POST /api/eventos/invitar-organizador, /api/eventos/speaker-lookup.
Panel de aprobación
/dashboard/eventos/admin/aprobacion (el /admin redirige aquí con 308).
Gate: RPC is_event_admin(uid); si no es admin → 403. El server trae todos los eventos (incluida la papelera), paginando de a 1000 por el tope de PostgREST. Se muestran todos los campos del form + trazabilidad (creado/aprobado/editado/eliminado por + fechas).
| Pestaña | Criterio |
|---|---|
| Totales | todos (activos + finalizados) |
| Activos | APPROVED && no finalizado |
| Pendientes de aprobación default | PENDING |
| Cambios por aprobar | CAMBIOS |
| Finalizados | (APPROVED|INACTIVO) && fecha fin < hoy |
| Rechazados | REJECTED |
| Borradores | DRAFT |
| Eliminados | deleted_at ≠ null |
| Acción | Condición | Llamada |
|---|---|---|
| Ver registro | siempre | modal solo lectura |
| Editar | rol editor && no finalizado | PATCH /api/admin/eventos/[id] action=editar |
| Aprobar | curador/admin && PENDING|CAMBIOS | action=aprobar |
| Rechazar | curador/admin && PENDING|CAMBIOS | action=rechazar (feedback opcional) |
| Duplicar | can_duplicate && APPROVED|INACTIVO | POST /api/eventos/[id]/duplicar → copia a Borrador |
| Eliminar | según protección (ver §17) | DELETE /api/eventos/[id] (soft, doble confirmación) |
| Restaurar | en papelera, curador/admin | POST /api/eventos/[id]/restore |
14 filtros acumulativos (modalidad, temática, país, ciudad, rango de fechas, superevento, actividad, costo, organizado/creado/aprobado/editado/eliminado por). Descargar CSV solo para super_admin. Toda la lógica de transición/validación/correos vive en PATCH /api/admin/eventos/[id]; el panel es solo UI.
Roles y permisos
Dos capas de almacenamiento — origen del gotcha principal.
admin_roles (rol GLOBAL): vocabulario vigente super_admin, curador. Los legacy moderador/revisor/editor fueron purgados (mig 077). Prod: 1 fila (super_admin).module_permissions (rol POR MÓDULO, ej. eventos): admin, curador, editor_confianza, duplicador, publicador, organizadores. Prod eventos: 1 fila = admin.Las funciones-gate vigentes (migración 080, que sobrescribe 073/077): is_super_admin, is_module_admin, has_module_permission, is_event_admin (= ver panel), has_event_role(['curador']) (= acciones de estado), can_duplicate, can_manage_organizadores.
| Capacidad | super_admin | admin (módulo) | curador | editor_confianza | duplicador | organizadores |
|---|---|---|---|---|---|---|
| Ver panel de aprobación | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| Aprobar / Rechazar / Cambios / Desactivar / Reactivar | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| Editar cualquier evento | ✅ | ✅ | ✅ | solo propios | ❌ | ❌ |
| Eliminar APPROVED no publicado | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| Eliminar publicado / finalizado | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Duplicar | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
| Descargar CSV | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Gestionar directorio de organizadores | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
| Fusionar organizadores | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Asignar roles (sección Permisos) | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
Estados del evento y transiciones
Enum DRAFT | PENDING | APPROVED | REJECTED | CAMBIOS | INACTIVO + estados derivados.
| Estado | Qué lo produce | Transiciones | Quién |
|---|---|---|---|
| DRAFT | creado sin enviar / duplicado | enviar → PENDING | dueño |
| PENDING | organizador envía a aprobación | aprobar→APPROVED · solicitar_cambios→CAMBIOS (feedback ≥10 obligatorio) · rechazar→REJECTED | curador/super_admin |
| CAMBIOS | el curador pide ajustes | aprobar→APPROVED · rechazar→REJECTED | curador/super_admin |
| APPROVED | aprobar / reactivar | desactivar→INACTIVO | curador/super_admin |
| REJECTED | rechazar (setea rejected_at) | reactivar→APPROVED | curador/super_admin |
| INACTIVO | desactivar (setea inactivated_at) | reactivar→APPROVED | curador/super_admin |
Estados derivados (no columnas de status): Publicado = APPROVED && (publish_at null o vencida); Programado = APPROVED con publish_at futura (el catálogo lo oculta hasta esa fecha); Finalizado = (APPROVED|INACTIVO) && fecha fin < hoy (solo lectura); Papelera = deleted_at ≠ null (+ delete_scheduled_for = +30d).
DELETE marca deleted_at/deleted_by/delete_scheduled_for (+30d). El cron purge-eventos: (1) manda a papelera los REJECTED con >30 días; (2) hard-delete de la papelera vencida excepto publicados/finalizados; (3) purga supereventos. api/cron/purge-eventosAprobación, edición y auditoría
Quién aprueba, qué se edita antes, y qué queda registrado.
Quién aprueba: las acciones de estado exigen has_event_role(['curador']) = super_admin o admin/curador del módulo eventos.
Qué se edita antes de aprobar: la acción editar tiene una whitelist de campos (todos los del form: contacto, nombre, modalidad, fechas, ubicación, temáticas, actividades, speakers, organizadores, links, costo). Están excluidos a propósito status, approved_*, rejected_*, deleted_*. Un evento finalizado es solo lectura salvo super_admin.
edited_at/edited_by) que se sobrescribe en cada edición — no hay historial. La bitácora central action_audit_log existe pero su enum de acciones solo cubre organizadores (creado/editado/evaluado/eliminado/vinculado/fusionado); ninguna acción de evento se registra ahí. La única traza extra son los correos disparados en cada transición.Autopublish / auto-aprobación (dos mecanismos, ambos vacíos en prod): event_autopublish_privileges = correos cuyo evento se publica sin curaduría; event_editor_privileges (editor de confianza) = al editar un APPROVED, como editar no toca status, el evento queda aprobado sin re-curaduría. Feedback asimétrico: al rechazar el comentario es opcional; al solicitar cambios es obligatorio (≥10 caracteres).
Directorio de organizadores
Crear, actualizar, fusionar, eliminar y detectar duplicados.
Tablas (no existe una tabla organizadores a secas): directorio_organizadores (una fila viva por nombre normalizado; estados PENDIENTE_COMPLETAR / COMPLETO), evento_organizadores (puente, 2.513 vínculos) y eventos.organizadores_raw (lo que digita el form, fuente de las páginas públicas). Ruta: /dashboard/eventos/organizadores, gate can_manage_organizadores.
POST /api/admin/organizadores: si es entidad registrada, el server re-lee su perfil y deriva nombre/rol/países (ignora esos campos del cliente); si no, exige rol estructurado + url_consulta. (2) Dual-write automático desde el submit/edición de un evento (syncOrganizadoresFromEvento): normaliza por lower(trim(nombre)), inserta como PENDIENTE_COMPLETAR, crea el vínculo con ON CONFLICT DO NOTHING. Idempotente.PATCH …/[id] con dos modos: datos (URL, regiones, y para no-registrados rol + correo; en registrados el nombre y rol vienen de la entidad y están bloqueados) y evaluacion (5 criterios manuales + 1 automático = calidad ponderada).POST …/fusionar. Zona de alto riesgo, irreversible, solo super_admin, con doble confirmación por nombre exacto. La entidad registrada siempre impone su nombre oficial. Lógica en SQL merge_organizadores (idempotente). Prod: 3 fusionados.deleted_at/deleted_by); nunca toca la entidad registrada. Prod: 3 eliminados.directorio_posibles_duplicados (índice trigram): dos vivos son posibles duplicados si sus nombres normalizados son iguales o su similitud trigram ≥ 0.6. Solo detecta, nunca fusiona automáticamente.organizador_criterios, editable por super_admin, suman 1.00): Pertinencia de temáticas 0.40 · Calidad de la información 0.20 · Impacto en el ecosistema 0.15 · Nivel de actividad 0.10 (automático) · Reputación/Idoneidad 0.10 · Fiabilidad 0.05.Gotchas — crítico para el scraping
Las trampas que hay que resolver antes de ingerir eventos.
pais_code no es un código: guarda el nombre en español, y el histórico mezcla inglés ("Brazil") y español ("Brasil"). Si el scraper mete "Brazil", getIsoAlpha2 devuelve 00 y rompe el segmento país del evento_codigo. Normalizar el catálogo de país es el primer paso.ubicacion_lat/lng no se persisten; hay que cargarlos por otra vía si el mapa del catálogo los necesita.evento_codigo (cadena M-PP-TTT-AA-NNNNN) vs codigo_unico (entero). No confundirlos; el NNNNN es el consecutivo anual, independiente del entero.(nombre, fecha_inicio) ni sobre link_registro. Una reingesta puede duplicar eventos; los códigos se generan al insertar, así que no sirven para deduplicar contra una fuente externa. Hay que diseñar la clave de deduplicación.PENDING (curaduría); solo correos en event_autopublish_privileges (hoy vacía) entran APPROVED.horarios es autogenerado por rango de fechas (una fila por día, máx 30). Respetar la forma [{fecha,hora_inicio,hora_fin}] con hora_fin > hora_inicio.999. La "temática principal" (para el TTT) es la marcada con principal:true.regen-event-catalogs.py, el form y el generador de código se desalinean.ubicacion_solo_registrados / link_acceso_solo_registrados) existen en DB pero el form no los renderiza ni envía → siempre false.admin_roles, pero los gates vigentes (mig 080) leen module_permissions. Un curador asignado por esa UI no obtiene acceso bajo las funciones actuales; los roles reales de módulo se asignan por /api/admin/module-permissions.Range/Prefer: count.