LPDI Actualizado: 09-ago-2026 · Versión 1 · SGE — Módulo de Eventos
Sistema LPDI · Subsistema de Gestión de Eventos (SGE)

Módulo de Eventos — Documentación técnica

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.

Fuente: repositorio lpdi-relacionamiento (rama master) · Prod eco.lpdi.co · Cifras verificadas contra la tabla eventos (service-role) el 09-ago-2026 · Stack SvelteKit 2 + Supabase.

2.514
eventos en la tabla
2.507
APPROVED (histórico migrado)
7
DRAFT · 0 pendientes/rechazados
1.176
organizadores en directorio
1
superevento · 0 eventos asociados
1

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:

Los 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.

Foto de producción (09-ago-2026): la tabla 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.

2

Formulario FE — los 4 pasos, campo por campo

Pasos declarados en EventFormShell.svelte:176. Rojo = obligatorio.

Paso 1 · Información de contacto

Campo técnicoEtiqueta en pantallaTipoOblig.ValidaciónRef.
contactEmailCorreo electrónicoemailno vacío + regex /^[^\s@]+@[^\s@]+\.[^\s@]+$/tpl 1096; val 181
passwordContraseñapasswordSí**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
passwordConfirmConfirmar contraseñapasswordSí**solo email nuevo. Debe coincidirtpl 1270; val 199
termsAcceptedAcepto los Términos y condicionescheckboxSí**solo email nuevo. Marcadotpl 1316; val 201
privacyAcceptedPolítica de Tratamiento de DatoscheckboxSí**solo email nuevo. Marcadotpl 1324; val 202
contactFirstNameNombrestextno vacíotpl 1334; val 182
contactLastNameApellidostextno vacíotpl 1363; val 183
contactPhoneTeléfonoPhoneInput (prefijo país + número)no vacíotpl 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).

Paso 2 · Datos clave del evento

Campo técnicoEtiquetaTipoOblig.NotasRef.
eventoNombreNombre del eventotexttpl 1451; val 210
superEventoIdAsociar a Super Eventoselect (SuperEventoPicker)Nodesde tabla super_eventos vigentestpl 1469
modalidadModalidad del eventoradiopresencial / online / híbrido / dualtpl 1483; val 211
organizadoresOrganizadoresOrganizerRows (nombre typeahead + rol)≥1 con nombre; rol obligatorio si hay nombretpl 1513; val 216
fechaInicioFecha de iniciotext dd-mmm-aaaa + date pickertpl 1535; val 212
fechaFin / useSameDateFecha de finalización / "Usar misma fecha"text + checkboxSí¹¹salvo useSameDate; debe ser ≥ iniciotpl 1579; val 213
zonaHorariaZona horariaselect (TimezonePicker)si online aparece aquí; si presencial/híbrido/dual aparece en el subpanel de ubicacióntpl 1636
horariosHorarios por díaScheduleRows (una fila por día)hora_inicio + hora_fin por día; fin > iniciotpl 1653; val 215
linkSesionLink al eventourlNosolo online/híbrido/dual; si presente debe ser URLtpl 1668; val 227

Subpanel de ubicación (solo si presencial/híbrido/dual):

Campo técnicoEtiquetaTipoOblig.NotasRef.
ubicacionTextoDirección o nombre del lugarLocationAutocomplete (Google Places)Notpl 1700
eventoPaisPaísRegionCountryPicker (single)tpl 1780; val 219
eventoCiudadCiudadtext (autollenado por Places, editable)tpl 1806; val 220
eventoEstadoProvincia / DepartamentotextSí²²condicionado: aparece solo si Ciudad tiene valortpl 1827; val 221

Paso 3 · Temática del evento

Campo técnicoEtiquetaTipoOblig.NotasRef.
tematicasTemática central del eventoEventThemePicker (multiselect categorizado)≥1tpl 1886; val 234
actividadesActividades a desarrollarEventActivityPicker (multiselect)≥1tpl 1928; val 235
hostsSpeakers / speakersModeSpeakers y/o HostsHostSpeakerRows (3 modos)modo "registrar" exige ≥1 completo; modos "confirmar" / "no-aplica" guardan centinelatpl 1952; val 236

Paso 4 · Registro y detalles

Campo técnicoEtiquetaTipoOblig.NotasRef.
costoInscripcionCosto de inscripciónradio4 valores (ver §4)tpl 1998; val 271
linkRegistroLink de registro de asistentesurlURL válidatpl 2024; val 272
linkEventoLink al organizador / al eventourlNono puede ser igual a linkRegistrotpl 2047; val 275
masInformacionMás información del eventotextarea (máx 500)Notpl 2068
Al enviar, 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-809
3

Origen 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 / selectFuenteDetalle (tabla o archivo)
ModalidadCódigosrc/lib/data/eventModalities.ts
Costo de inscripciónCódigosrc/lib/data/eventCostOptions.ts
Temáticas (no sectoriales)Código espejosrc/lib/data/eventThemes.ts — espejo de tabla catalogo_tematica_evento vía scripts/regen-event-catalogs.py
Temáticas (sectorial)Código derivadoderiva de industriesTech.ts + industriesTraditional.ts (espejo de catalogo_industria)
ActividadesCódigo espejosrc/lib/data/eventActivities.ts — espejo de catalogo_actividad_evento
Zona horariaCódigosrc/lib/data/timezones.ts (TIMEZONES)
País (selector del form)Códigosrc/lib/data/countries.ts vía RegionCountryPicker — NO lee de tabla
Roles de organizadorTablacatalogo_rol_ecosistema (getEcosystemRolesGrouped, taxonomies.ts:121)
SupereventosTablasuper_eventos (filtrados por año de finalización ≥ actual)
Organizador (nombre) typeaheadTabla vía APIGET /api/entidades/search — Startup/PyME/ICG + directorio
Perfil de speakerCódigoPERFIL_OPTIONS en src/lib/utils/speakerValidation.ts:18
Ciudad / lugarExternoGoogle Places API (LocationAutocomplete.svelte)
Ojo: el catálogo 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.
4

Listas cerradas y su valor almacenado

No la etiqueta visible, sino el valor que queda en la base de datos.

Modalidad → eventos.modalidad

Valor almacenadoEtiquetaEn prod
presencialPresencial260
onlineOnline645
hibridoHíbrido93
dualDual2

CHECK en DB: modalidad IN ('presencial','online','hibrido','dual').

Costo de inscripción → eventos.costo_inscripcion

Valor almacenadoEtiquetaEn prod
ENTRADA_LIBRE_SIN_REGISTROEntrada libre -NO requiere registro previo-7
ENTRADA_LIBRE_CON_REGISTROEntrada libre -Requiere registro previo-551
POR_INVITACIONPor invitación -Requiere registro y aprobación-242
SISí -Tiene costo-200

Temáticas → 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íaSubcategorías (id almacenado)
emprendimientoemprendimiento, modelos-de-negocio
innovaciontecnologia, inteligencia-artificial, gestion-innovacion
inversionaceleracion, inversion-de-riesgo, levantamiento-de-capital
economiacontexto-economico, negocios-internacionales
estrategia-gestionestrategia, finanzas, gestion, legal, operaciones, rrhh, sostenibilidad
marketing-ventasecommerce, marketing, ventas
desarrollo-profesionalhabilidades-blandas, liderazgo, networking
otros-temaseducacion, otra
sectorial>130 ids derivados de sub-industrias Tech + Tradicional (ej. fintech-financial, healthtech, agrotech, retail, banca…) — lista completa en eventThemeCodes.ts:44-186

Actividades → eventos.actividades (JSONB [{categoria, subcategoria}])

CategoríaSubcategorías (id)
academicacapacitacion, clase, conferencia, conversatorio, curso, debate, foro, masterclass, taller, webinar, workshop
networkingactividad-bienestar, actividad-deportiva, after-party, almuerzo, cena, coctel, desayuno, networking-session
matchmakingdemo-day, muestra-comercial, pitch-practice, reverse-pitch, rueda-de-negocios, sesion-informativa
tech-weekstech-week
otrootro

Perfil de speaker → hosts_speakers[].perfil

Speaker · Keynote Speaker / Orador central · Moderador · Panelista · Host. Centinelas (modo no-registrar): "No Aplica", "Por confirmar".

Zona horaria → 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.

País → eventos.pais_code

El nombre de la columna engaña: pais_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.
5

Validaciones, condicionados y reglas de dependencia

Qué se muestra, se oculta o se exige según otros campos.

6

Fecha, hora, zona horaria y modalidad

Cómo se capturan y almacenan, y cómo cambia el form según la modalidad.

7

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.

Georreferencia perdida: aunque el componente deriva 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.
8

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).

En producción hay 1 superevento y 0 eventos asociados: la agrupación existe pero hoy no se usa.
9

Organizadores en el formulario

Con entidad registrada vs organizador "raw" (sin entidad).

Componente OrganizerRows.svelte. Cada fila es {nombre, rol, entityType?, entityId?}:

10

Códigos del evento (Sistema de Codificación, SC)

Son DOS columnas distintas — fácil de confundir.

1) evento_codigo (TEXT) — la cadena legible

Patrón M-PP-TTT-AA-NNNNN, generado server-side en el submit final:

SegmentoQué esRegla
MModalidadpresencial→P, online→V, hibrido→H, dual→D
PPPaísISO alpha-2 (2 letras) vía getIsoAlpha2(pais); online → 00
TTTTemática principal3 dígitos (mapa de 161 códigos en eventThemeCodes.ts); fallback 999
AAAño2 dígitos del año de fecha_inicio
NNNNNConsecutivocorrelativo 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).

2) codigo_unico (entero) — correlativo interno

Un 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).

Inconsistencia entre generadores: el segmento de país de 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.
11

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:

namecontenido
contactEmailemail (lowercased)
contactFirstName / contactLastName / contactPhonestrings
context'public' | 'dashboard'
password / emailCheckStatussolo si requiere validar contraseña
termsAccepted / privacyAcceptedsolo si el email es nuevo
isLoggedIn'true' | 'false'
eventoNombrestring
superEventoIduuid o ''
modalidadpresencial/online/hibrido/dual
organizadoresRawJSON [{nombre,rol,entityType?,entityId?}]
fechaInicio / fechaFinyyyy-mm-dd
zonaHorariaIANA
horariosJSON [{fecha,hora_inicio,hora_fin}]
ubicacionTexto / eventoPais / eventoEstado / eventoCiudadstrings
tematicasJSON [{categoria,subcategoria,principal?}]
actividadesJSON [{categoria,subcategoria}]
hostsSpeakersJSON [{email,nombres,apellidos,perfil,linkedin}]
speakersModeregistrar/confirmar/no-aplica
costoInscripcionuno de los 4 valores
linkRegistro / linkEvento / linkSesion / masInformacionstrings
eventoIduuid del draft (si existe)
No se envían (aunque el server los procesa/existen en DB): ubicacionSoloRegistrados / linkSoloRegistrados (el shell no los mete → siempre false), ubicacion_lat/lng, zona_horaria_remota, contact_phone_country_code.
12

Esquema real de la tabla eventos

Base: migración 049_eventos_schema.sql + parches. Columnas, tipos y orígenes.

ColumnaTipoNotas
idUUID PKgen_random_uuid()
registered_byUUID FK→auth.usersusuario creador
contact_emailTEXT NOT NULL
contact_first_name / contact_last_nameTEXT
empresa_registroTEXTdel perfil, no del form
contact_phone / contact_phone_country_codeTEXTcountry_code no lo puebla el form
nombreTEXT NOT NULL
super_evento_idUUID FK→super_eventos
modalidadTEXT NOT NULLCHECK (4 valores)
fecha_inicio / fecha_finalizacionDATE NOT NULLCHECK fin ≥ inicio
horariosJSONBuna fila por día
zona_horariaTEXT NOT NULLdefault America/Bogota
zona_horaria_remotaTEXTno lo puebla el form
ubicacion_textoTEXTdirección / nombre del lugar
ubicacion_lat / ubicacion_lngNUMERIC(10,7)no se persisten desde el form (NULL)
pais_codeTEXTguarda el nombre, no ISO
estado_provincia / ciudadTEXT
tematicas / actividades / hosts_speakersJSONB
costo_inscripcionTEXT NOT NULLCHECK (4 valores)
link_registro / link_evento / link_sesionTEXT
mas_informacionTEXTCHECK ≤ 500
codigo_unicoint UNIQUEera SERIAL; mig 134 quitó DEFAULT/NOT NULL
evento_codigoTEXTúnico parcial uq_eventos_evento_codigo
ubicacion_solo_registrados / link_acceso_solo_registradosBOOL default falsehoy siempre false (form no los manda)
statusTEXT NOT NULL default 'DRAFT'CHECK: DRAFT, PENDING, APPROVED, REJECTED, CAMBIOS, INACTIVO
feedbackTEXTcomentario de curaduría
approved_at / approved_byTZ / UUID
rejected_at / inactivated_atTZ
edited_at / edited_byTZ / UUIDúltimo editor (se sobrescribe)
deleted_at / deleted_by / delete_scheduled_forTZ / UUID / TZsoft-delete + purga +30d
publish_atTZpublicación programada
responsable_idUUID
es_pruebaBOOL default falsedatos sembrados (prod = 0)
organizadores_rawJSONBorganizadores del form
created_at / updated_atTZtrigger 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).

13

Campos derivados/calculados en el server

No vienen del formulario; los pone el +page.server.ts.

14

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.

15

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ñas

PestañaCriterio
Totalestodos (activos + finalizados)
ActivosAPPROVED && no finalizado
Pendientes de aprobación defaultPENDING
Cambios por aprobarCAMBIOS
Finalizados(APPROVED|INACTIVO) && fecha fin < hoy
RechazadosREJECTED
BorradoresDRAFT
Eliminadosdeleted_at ≠ null

Acciones por fila (según estado y rol)

AcciónCondiciónLlamada
Ver registrosiempremodal solo lectura
Editarrol editor && no finalizadoPATCH /api/admin/eventos/[id] action=editar
Aprobarcurador/admin && PENDING|CAMBIOSaction=aprobar
Rechazarcurador/admin && PENDING|CAMBIOSaction=rechazar (feedback opcional)
Duplicarcan_duplicate && APPROVED|INACTIVOPOST /api/eventos/[id]/duplicar → copia a Borrador
Eliminarsegún protección (ver §17)DELETE /api/eventos/[id] (soft, doble confirmación)
Restauraren papelera, curador/adminPOST /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.

16

Roles y permisos

Dos capas de almacenamiento — origen del gotcha principal.

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.

Capacidadsuper_adminadmin (módulo)curadoreditor_confianzaduplicadororganizadores
Ver panel de aprobación
Aprobar / Rechazar / Cambios / Desactivar / Reactivar
Editar cualquier eventosolo propios
Eliminar APPROVED no publicado
Eliminar publicado / finalizado
Duplicar
Descargar CSV
Gestionar directorio de organizadores
Fusionar organizadores
Asignar roles (sección Permisos)
17

Estados del evento y transiciones

Enum DRAFT | PENDING | APPROVED | REJECTED | CAMBIOS | INACTIVO + estados derivados.

EstadoQué lo produceTransicionesQuién
DRAFTcreado sin enviar / duplicadoenviar → PENDINGdueño
PENDINGorganizador envía a aprobaciónaprobar→APPROVED · solicitar_cambios→CAMBIOS (feedback ≥10 obligatorio) · rechazar→REJECTEDcurador/super_admin
CAMBIOSel curador pide ajustesaprobar→APPROVED · rechazar→REJECTEDcurador/super_admin
APPROVEDaprobar / reactivardesactivar→INACTIVOcurador/super_admin
REJECTEDrechazar (setea rejected_at)reactivar→APPROVEDcurador/super_admin
INACTIVOdesactivar (setea inactivated_at)reactivar→APPROVEDcurador/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).

Soft-delete y purga: 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-eventos
18

Aprobació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.

No hay tabla de auditoría para eventos. El "quién editó" es un único par de columnas (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).

19

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.

Criterios de calidad (tabla 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.
20

Gotchas — crítico para el scraping

Las trampas que hay que resolver antes de ingerir eventos.

  1. 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.
  2. Georreferencia siempre NULL desde el form: ubicacion_lat/lng no se persisten; hay que cargarlos por otra vía si el mapa del catálogo los necesita.
  3. Dos códigos distintos: evento_codigo (cadena M-PP-TTT-AA-NNNNN) vs codigo_unico (entero). No confundirlos; el NNNNN es el consecutivo anual, independiente del entero.
  4. Sin unicidad natural: no hay constraint sobre (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.
  5. Insert bloqueado por RLS para anon: toda ingesta masiva debe usar service-role. El status inicial normal es PENDING (curaduría); solo correos en event_autopublish_privileges (hoy vacía) entran APPROVED.
  6. 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.
  7. Temáticas sectoriales: sus ids salen de las sub-industrias (>130); un id fuera del mapa cae a código 999. La "temática principal" (para el TTT) es la marcada con principal:true.
  8. Catálogos hardcodeados = espejos de tablas: si se cambia la tabla en DB sin correr regen-event-catalogs.py, el form y el generador de código se desalinean.
  9. Checkboxes de visibilidad restringida (ubicacion_solo_registrados / link_acceso_solo_registrados) existen en DB pero el form no los renderiza ni envía → siempre false.
  10. Drift de roles: la UI de "Permisos" escribe en 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.
  11. Tope PostgREST 1000 filas: cualquier consulta directa al REST corta a 1000 salvo que se pagine con Range/Prefer: count.
  12. La cola de curaduría está vacía y solo 2 personas administran: hoy no hay eventos PENDING; casi todo el histórico entró como APPROVED por importación.