Proyecto: LPDI SGR (eco.lpdi.co) · Fecha: 23 de julio de 2026 · Estado: PLAN v3 — incorpora tus 6 decisiones sobre el v2; para tu aprobación FINAL antes de ejecutar
| # | Tu decisión sobre el v2 | Cómo quedó en el v3 |
|---|---|---|
| 1 | Año del código (4b): CONFIRMADO = año de registro en el sistema (fecha de formalización) | Cerrado. Ya no aparece en las recomendaciones a confirmar. §5.1 |
| 2 | Consecutivo NNNNN: SEPARADO por tipo (startups e ICG llevan secuencias independientes), a 5 dígitos | Rediseñado el contador: tabla única con clave (tipo, año) — dos secuencias por año, una S y una I — RPC atómica, mismo patrón de eventos. §5.1 |
| 3 | Prefijo de categoría S/I al inicio del código | Formato nuevo: S-TTT-PPP-AA-NNNNN (startups) / I-TTT-PPP-AA-NNNNN (ICG). Reescritos §5.1, §5.3 y todos los ejemplos del documento. Nota 5 vs 6 dígitos en §9.1 |
| 4 | Correo de expiración a ambas partes: aprobado tal cual | Cerrado. La matriz de correos §4.4 queda como estaba en el v2; sale de las recomendaciones a confirmar |
| 5 | codigo_unico SERIAL de eventos: deja de ser deuda, ENTRA AL PLAN como tarea ("hay que resolverlo; si quiere lo deja al final, pero debe hacer parte del plan") |
Nueva Task 14 (Ola 5, la última): asignar el codigo_unico solo al enviar el evento, nunca en el borrador del autosave, sin romper los enlaces públicos /eventos/{codigo} existentes. Diseño verificado en el código, §5.6 y Task 14. Sale de la §9 |
| 6 | Países: NO hay data-fix ISO2 — eliminado del plan | Verificado y corregido: el supuesto "valores legacy ISO2" del v2 era equivocado. startups.country/icgs.country guardan el nombre oficial en español; el SSOT es COUNTRIES en src/lib/data/countries.ts y el prefijo sale de getPhonePrefix3 de ese mismo listado. El índice único por país se mantiene (sigue siendo necesario), pero sin paso de data-fix. Reescritos §1, §3.4 y Task 3. Un matiz verificado con evidencia en §1 (la fila "Peru" sin tilde) que se resuelve en el helper, no con data-fix |
Todo lo demás del v2 se mantiene intacto: el hallazgo del índice único muerto (§1), la tarjeta con correos enmascarados y rótulo "Admin en el sistema" (§3.2), el flujo de asociación con permisos + cargo gestionado en PS/PI y correos a ambas partes (§4), la taxonomía de 43 códigos (§5.2, ahora con el prefijo S/I delante), el barrido de duplicados (§6), la columna de código en las dos consultas (§7), el código visible en PS/PI (§5.5) y la regla transversal "nada en borrador recibe código" (§5.6).
Lo esencial sigue vigente: nada impide que dos usuarios distintos registren la misma empresa; el aviso del formulario es ignorable; el endpoint /api/check-company-name solo busca coincidencia exacta insensible a mayúsculas (no cruza startups contra ICGs ni usa el nombre legal).
Hallazgo del v2 que se mantiene — el índice único actual está muerto:
030 + 036) es: UNIQUE (registered_by_id, company_name) WHERE company_name != '' AND status != 'DRAFT' (y su gemelo en ICGs con organization_name).114 (formalización, 12-jul), el estado real de una entidad es formalized_at (NULL = borrador, con fecha = activa). La columna status quedó legacy: las entidades se insertan con status='DRAFT' y nada la cambia jamás (el propio código de la consulta admin lo documenta: "status NO SIGNIFICA NADA — no existe proceso de aprobación").status != 'DRAFT' hace que el índice no aplique a ninguna fila. Hoy un mismo usuario puede registrar dos veces "Acme" formalizada y la base no lo impide.psql el estado real en producción (regla nuestra: el estado real ≠ los archivos de migración), pero el código es concluyente.Esto convierte el rediseño del constraint en una reparación necesaria, y el barrido de duplicados en prerrequisito técnico: hay que resolver las colisiones existentes antes de crear el índice nuevo, o el CREATE UNIQUE INDEX falla.
Países — hallazgos verificados (corrige el supuesto del v2):
COUNTRIES en src/lib/data/countries.ts (nombre_es, iso_alpha2, iso_alpha3, region_lpdi, codigo_telefono). Es el listado oficial armonizado que usa TODO el sistema. No existe ni se creará ningún listado paralelo.getPhonePrefix3(paisOrNombre) lee codigo_telefono y acepta tanto el ISO2 como el nombre_es ("Colombia" → 057, "CO" → 057), rellenado/truncado a 3 dígitos; sin país o país no reconocido → 000.startups.country / icgs.country guardan el nombre oficial ("Colombia", "Perú", "México"…). No hay valores ISO2 (verificado: cero filas con country de 3 caracteres o menos). El "data-fix ISO2→nombre" del v2 fue un supuesto equivocado y queda eliminado del plan.country vacío (→ prefijo 000, comportamiento correcto para entidad sin país declarado) y una fila "Peru" sin tilde. Matiz verificado con evidencia (ejecutado contra el código real): el match por nombre de getPhonePrefix3 es insensible a mayúsculas pero NO a tildes — hoy getPhonePrefix3('Peru') devuelve 000 y getPhonePrefix3('Perú') devuelve 051. No amerita data-fix: el helper compositor del código de entidad (Task 8) normaliza tildes antes del lookup (envoltorio accent-insensitive sobre getPhonePrefix3), lo que resuelve esa fila y blinda cualquier futura. Para el índice único, immutable_unaccent(country) ya trata "Peru" y "Perú" como el mismo país.company_name_norm / organization_name_norm (lower + unaccent, migración 072) con índice — se reutilizan para el matching y el constraint. La extensión pg_trgm ya está habilitada (migración 049) — el matching aproximado no necesita extensiones nuevas.La propuesta sigue siendo correcta y ejecutable: convertir el aviso pasivo en una decisión consciente (crear nueva vs. solicitar asociación), con verificación humana de pertenencia por quien administra la entidad existente. Con tus 6 decisiones sobre el v2, quedan cerradas todas las decisiones de diseño; solo restan las dos confirmaciones menores de la §9.
Nuevo endpoint que busca contra startups e ICGs en dos niveles — (1) coincidencia exacta normalizada (sin tildes ni mayúsculas, reutilizando company_name_norm/organization_name_norm); (2) coincidencia aproximada por similitud (pg_trgm) que captura "Acme SAS" vs "Acme S.A.S." y errores de tipeo. Compara nombre comercial, nombre legal y nombre de ICG. Solo entidades formalizadas y no eliminadas.
Encontramos entidades parecidas a "Acme"
┌──────────────────────────────────────────────────┐
│ ACME S.A.S. (Startup) · Colombia │
│ Código: S-STA-057-26-00042 │
│ Nombre legal: Acme Tecnología S.A.S. │
│ Admin en el sistema: Juan Pérez (j***@acme.com) │
│ CEO: María Gómez (m***@acme.com) │
│ Web: acme.com · Coincidencia exacta │
│ [Es mi empresa, solicitar asociación] │
└──────────────────────────────────────────────────┘
[No es ninguna, crear entidad nueva]
*** + dominio completo (j***@acme.com). El enmascarado se hace en el servidor — el correo completo nunca viaja al navegador.Siempre permitido. Cuando el usuario ignora una coincidencia exacta, se deja rastro en action_audit_log con un action_type nuevo (entity_created_over_exact_match), incluyendo en metadata los ids de las entidades que coincidían. Nota técnica obligatoria (gotcha conocido de este repo): agregar un action_type implica tocar el tipo TS y el CHECK constraint de action_audit_log (última versión del CHECK en la migración 128; logAction traga el error si no se hace → falla silenciosa).
Reemplaza al índice muerto. El país ya está almacenado con su nombre oficial en toda la data real (verificado, §1), así que el índice solo necesita la normalización unaccent que ya se usa en el resto del sistema — no hay paso previo de corrección de datos. Diseño:
-- startups (gemelo equivalente en icgs con organization_name)
DROP INDEX IF EXISTS startups_registered_by_company_unique;
CREATE UNIQUE INDEX startups_registered_by_company_country_unique
ON startups (registered_by_id, immutable_unaccent(company_name), immutable_unaccent(country))
WHERE company_name != ''
AND formalized_at IS NOT NULL
AND deleted_at IS NULL;
Qué logra, punto por punto:
| Caso | Resultado |
|---|---|
| Mismo usuario, mismo nombre, mismo país | Bloqueado (la protección real a conservar) |
| Mismo usuario, mismo nombre, países distintos | Permitido (tu regla: "Acme Colombia" y "Acme Perú" del mismo usuario conviven) |
| Mismo usuario, N borradores con el mismo nombre | Permitido (los borradores no cuentan — formalized_at IS NULL) |
| Usuarios distintos, mismo nombre | Permitido a nivel de base (como hoy) — lo gobierna el flujo de coincidencias + asociación, no un bloqueo duro |
| "Ácme" vs "acme" · "Peru" vs "Perú" | Cuentan como iguales (normalización con immutable_unaccent, ya existente) |
Prerrequisitos en orden (por eso el barrido va primero):
1. Pre-chequeo de colisiones: query que detecta filas que violarían el índice nuevo. Si hay colisiones, van al informe del barrido (§6) y las resuelve el equipo LPDI caso por caso ANTES de crear el índice (si no, el CREATE UNIQUE INDEX falla).
2. Crear el índice nuevo y eliminar el viejo. Todo idempotente y reversible, con snapshot previo (Supabase sin PITR).
Nueva tabla entity_join_requests con token. Quién aprueba = exactamente quien ya puede editar la entidad: en startup, el registrador, el CEO o un miembro verificado con edición; en ICG, solo el registrador (regla dura tuya: los contactos ICG nunca editan). El solicitante debe estar verificado (Opción B). Vigencia 10 días, recordatorio al día 5 — replicando el motor temporal ya probado de las transferencias (RPC tick + cron + correos, patrón de entity_transfer_tick).
Verificado en el código que el panel de la entidad es el lugar correcto y consistente con cómo se gestiona hoy el equipo:
startup_founders, que ya tiene las columnas exactas necesarias — role (cargo), can_edit_form (permiso de edición) e is_ceo — gestionadas hoy en el Panel 5C "Equipo" del formulario de startup, al que se llega desde el Panel de Startup (/dashboard/perfil-startup). La vista personal "Gestión de permisos" del PU muestra estos mismos roles (Admin / CEO / Miembro / Miembro + Edición).icg_contacts (con campo position = cargo y verification_status), gestionados desde el formulario ICG al que se llega por el Panel de ICG (/dashboard/perfil-icg).Flujo: las solicitudes pendientes aparecen en el PS (startup) o PI (ICG) de la entidad. Al aprobar, en esa misma pantalla el aprobador asigna de una vez:
role) y los permisos (can_edit_form sí/no; default: sin edición). El aprobado entra como miembro verificado (ambas partes consintieron: él solicitó, el aprobador aceptó).position). No hay permiso de edición que asignar — los contactos ICG nunca editan (tu regla dura se mantiene intacta); el aprobado entra como contacto verificado.pending → (approved | rejected | expired a los 10 días). Recordatorio al aprobador el día 5. Todo transiciona vía RPC atómica (sin dobles aprobaciones) y deja rastro en action_audit_log (action_types nuevos: join_request_created, join_request_approved, join_request_rejected, join_request_expired — mismo gotcha del CHECK, §3.3).
| Evento | Al solicitante | Al aprobador / aprobadores |
|---|---|---|
| Solicitud creada | ✅ constancia "tu solicitud fue enviada a los administradores de X" | ✅ con la tarjeta del solicitante y el enlace de decisión |
| Día 5 sin respuesta | — | ✅ recordatorio |
| Aprobada | ✅ "ya haces parte de X" (con su cargo) | ✅ confirmación de lo que aprobó |
| Rechazada | ✅ notificación (sin exponer quién rechazó) | ✅ constancia |
| Expirada (día 10) | ✅ "venció sin respuesta; puedes crear la entidad o volver a intentar" | ✅ constancia informativa |
La fila de expiración a ambas partes quedó aprobada — ya no es una recomendación pendiente. Los renders de correo siguen el patrón del repo (script de render separado, mismo layout de los correos de entidad existentes).
S-TTT-PPP-AA-NNNNN para startups · I-TTT-PPP-AA-NNNNN para ICG.
Ejemplos: S-STA-057-26-00042, I-VCF-051-26-00012.
| Segmento | Qué es | Detalle |
|---|---|---|
S / I |
Prefijo de categoría (1 letra) | S = startup, I = ICG. Determinado por la tabla donde vive la entidad (startups / icgs), no por el catálogo |
TTT |
Tipo granular (3 letras) | Subcategoría de segundo nivel, desde tabla de catálogo (§5.2) |
PPP |
Prefijo telefónico del país (3 dígitos) | Mismo criterio del código de eventos: getPhonePrefix3 sobre el SSOT COUNTRIES de countries.ts — Colombia → 057, Perú → 051, México → 052; sin país → 000. El helper compositor normaliza tildes antes del lookup (matiz "Peru" de §1) |
AA |
Año (2 dígitos) | CONFIRMADO por ti: año de REGISTRO EN EL SISTEMA (fecha de formalización, formalized_at) |
NNNNN |
Consecutivo a 5 dígitos, SEPARADO por tipo | CONFIRMADO por ti: dos secuencias independientes — una para startups (S) y otra para ICG (I) — por año. Ver diseño del contador abajo. Nota 5 vs 6 dígitos en §9.1 |
Contador separado por tipo (tu punto 2): una sola tabla entidad_codigo_counters con clave compuesta que incluye el tipo — dos secuencias limpias sin duplicar infraestructura:
CREATE TABLE entidad_codigo_counters (
tipo CHAR(1) NOT NULL CHECK (tipo IN ('S','I')),
anio SMALLINT NOT NULL,
consecutivo INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (tipo, anio)
);
RPC atómica next_entidad_consecutivo(p_tipo, p_anio) con INSERT ... ON CONFLICT (tipo, anio) DO UPDATE SET consecutivo = consecutivo + 1 ... RETURNING — réplica exacta del patrón probado de eventos (evento_codigo_counters + next_evento_consecutivo), sin colisiones por concurrencia. Así, S-...-26-00042 es la startup n.º 42 formalizada en 2026 y I-...-26-00042 es la ICG n.º 42 — secuencias que no se cruzan.
Nueva tabla de catálogo catalogo_codigo_tipo_entidad: mapea cada subcategoría de segundo nivel a su código corto de 3 letras. Agregar una categoría o subcategoría nueva = insertar 1 fila (no tocar código de aplicación). El prefijo S/I NO vive en esta tabla — lo aporta el compositor según la tabla de origen de la entidad.
CREATE TABLE catalogo_codigo_tipo_entidad (
id SERIAL PRIMARY KEY,
rol_codigo VARCHAR(100) NOT NULL UNIQUE, -- referencia al codigo de catalogo_rol_ecosistema
codigo_corto CHAR(3) NOT NULL UNIQUE, -- el TTT del código de entidad
activo BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Cómo conecta con lo que ya existe (verificado en el código):
catalogo_rol_ecosistema (43 filas: codigo, label, categoria) ya contiene el segundo nivel completo, y ya cubre a las startups: tiene las filas Startup, Scaleup y PyME (categoría "Startup"). Es la columna vertebral para colgar el código corto.S): company_type (enum STARTUP/PYME/SCALEUP) → fila del catálogo (Startup/PyME/Scaleup) → codigo_corto.I): la columna icg_role guarda exactamente el codigo del catálogo (p. ej. Venture Capital Fund - VC) → codigo_corto directo. Fallback para ICGs legacy sin icg_role: código OTR.Seed propuesto (los 43 códigos — puedes ajustar cualquier sigla, §9.2):
| Categoría | Subcategoría (2.º nivel) | Código |
|---|---|---|
| Inversionista Individual | Ángel Inversionista | ANG |
| Inversionista Individual | Limited Partner | LIP |
| Inversionista Individual | General Partner | GEP |
| Inversionista Individual | Lead Investor | LIN |
| Inversionista Institucional | Family Office | FAM |
| Inversionista Institucional | Fondo de Inversión | FDI |
| Inversionista Institucional | Banca de Inversión | BAN |
| Inversionista Institucional | Venture Capital Fund – VC | VCF |
| Inversionista Institucional | Corporate Venture Capital – CVC | CVC |
| Inversionista Institucional | Government Venture Capital – GVC | GVC |
| Gestor especializado | Incubadora | INC |
| Gestor especializado | Aceleradora | ACE |
| Gestor especializado | Cámara de Comercio | CAM |
| Gestor especializado | Caja de Compensación | CDC |
| Gestor especializado | Company Builder | CBU |
| Gestor especializado | Startup Studio | SST |
| Corporativo | Empresa Privada | EMP |
| Corporativo | Empresa Mixta | EMX |
| Consultora | Gran consultora | GCO |
| Consultora | Consultora boutique | CBO |
| Consultora | Consultor independiente | COI |
| Startup | Startup | STA |
| Startup | Scaleup | SCA |
| Startup | PyME | PYM |
| Gobierno | Entidad Pública Municipal | GPM |
| Gobierno | Entidad Pública Regional | GPR |
| Gobierno | Entidad Pública Nacional | GPN |
| Gobierno | Organismo Multilateral | OMU |
| Academia e Innovación | Universidad | UNI |
| Academia e Innovación | Centro de Innovación | CEI |
| Academia e Innovación | Centro de Investigación | CEV |
| Academia e Innovación | Otros centros de formación | OCF |
| Hub y Redes | Hub de Innovación | HUB |
| Hub y Redes | Red de Innovación | RDI |
| Hub y Redes | Red de Emprendimiento | RDE |
| Hub y Redes | Clúster Tecnológico | CLU |
| Hub y Redes | Comunidad | COM |
| Otros | Asociación | ASO |
| Otros | Gremio | GRE |
| Otros | Fundación | FUN |
| Otros | Medio de comunicación | MED |
| Otros | ONG | ONG |
| Otros | Otro | OTR |
| Entidad | Código | Lectura |
|---|---|---|
| Startup en Colombia, formalizada en 2026, consecutivo 42 de startups | S-STA-057-26-00042 |
Startup · tipo Startup · Colombia (+57) · 2026 · #42 de la secuencia S |
| PyME en México, 2026, consecutivo 87 de startups | S-PYM-052-26-00087 |
Startup · tipo PyME · México (+52) · 2026 · #87 de la secuencia S |
| ICG fondo VC en Perú, 2026, consecutivo 12 de ICG | I-VCF-051-26-00012 |
ICG · Venture Capital Fund · Perú (+51) · 2026 · #12 de la secuencia I |
| ICG Cámara de Comercio en Colombia, 2026, consecutivo 104 de ICG | I-CAM-057-26-00104 |
ICG · Cámara de Comercio · Colombia (+57) · 2026 · #104 de la secuencia I |
Nota: el código es inmutable desde su asignación. Si la entidad luego cambia de tipo o de país, el código no se regenera (es un identificador, no un atributo vivo) — igual criterio que eventos.
formalizeEntity() — el único punto del código por donde pasa toda formalización (público y dashboard), ya idempotente a nivel de base. Los borradores abandonados no queman números.formalized_at ascendente (las más antiguas reciben los números más bajos), con el año de su propia formalización en AA y cada tabla consumiendo su propia secuencia (startups la S, ICGs la I).Nada que esté en BORRADOR recibe código. Los códigos únicos del sistema (entidades, eventos, convocatorias, noticias y cualquier módulo futuro) se asignan únicamente al formalizar/enviar, nunca al crear o autoguardar un borrador. Un borrador abandonado no debe quemar números ni dejar identificadores huérfanos.
Estado de cada identificador de eventos frente a la regla (verificado en el código):
evento_codigo) cumple: se genera únicamente en el submit final vía next_evento_consecutivo; el autosave de borradores lo deja en NULL (documentado en el propio código de registro-evento/+page.server.ts: "evento_codigo NO se genera en drafts").codigo_unico de eventos NO cumple: es columna SERIAL UNIQUE (migración 049), Postgres lo asigna al insertar la fila — incluido el insert del borrador del autosave. Un borrador abandonado quema un número.Por tu decisión, esto YA NO es deuda: es la Task 14 de este plan (Ola 5, la última). Diseño del fix, basado en cómo se usa hoy codigo_unico (investigado archivo por archivo):
Uso actual de codigo_unico |
Archivo | Impacto del fix |
|---|---|---|
Enlace público de compartir /eventos/{codigo} |
src/routes/eventos/[codigo]/+page.server.ts → getEventoPublicoByCodigo |
Solo sirve eventos APPROVED no eliminados — los códigos de borradores jamás se exponen públicamente. Anular el código de los borradores no rompe ningún enlace |
| Ficha/lookup por código | src/lib/server/eventosPublicos.ts (.eq('codigo_unico', ...) + SELECT_COLS) |
Sin cambio: los eventos publicados conservan su código actual (no se renumera nada) |
| Mensaje de compartir por WhatsApp | src/lib/utils/eventoShare.ts (codigoUnico en EventoShareData) |
Ya modela codigoUnico: number \| null; con el fix, los borradores lo tendrán en NULL — la UI debe ocultar/deshabilitar compartir en borradores (verificación en Task 14) |
| Listados Mis eventos / Aprobación admin | dashboard/eventos/mios/+page.server.ts, dashboard/eventos/admin/aprobacion/+page.server.ts |
Ya mapean (e.codigo_unico as number) ?? null — toleran NULL |
| Duplicar evento | api/eventos/[id]/duplicar/+server.ts |
Ya omite codigo_unico en la copia (comentario en el código); hoy el SERIAL le asigna uno al insertar — con el fix quedará NULL hasta el envío, que es lo correcto |
super_eventos.codigo_unico |
migración 049 (también SERIAL) |
Fuera de alcance: los super eventos son admin-only y se crean directo, sin flujo de borrador — la regla "nada en borrador recibe código" no les aplica |
El fix (detalle en Task 14): (1) migración que quita el DEFAULT nextval(...) y el NOT NULL de eventos.codigo_unico conservando la secuencia existente, y anula (NULL) el codigo_unico de las filas en DRAFT (snapshot previo; seguro porque los borradores nunca son públicos); (2) el submit final de registro-evento asigna el codigo_unico desde la secuencia (RPC atómica), en el mismo punto donde hoy se genera evento_codigo — una sola vez, idempotente; (3) los eventos ya enviados/aprobados/rechazados conservan su número actual (cero renumeración → cero enlaces rotos); los números ya quemados por borradores viejos no se reciclan (la secuencia sigue donde va — es un identificador, no un censo).
El código de entidades nace ya conforme a la regla (solo en formalizeEntity).
Informe solo lectura sobre la base actual (sin tocar ni un dato), con el mismo matching del §3.1:
Verificado el estado actual de ambas páginas:
/dashboard/consulta/startups: columnas Compañía · Tipo · Estado · Transferida · Ubicación · TRL · CRL · Base tec. · Completitud · Sitio web · Acciones (sticky)./dashboard/consulta/icg: Organización · Tipo · Estado · Transferida · Perfil · Ubicación · Convocatoria · Completitud · Sitio web · Acciones (sticky).Se agrega Código inmediatamente después del nombre (Compañía/Organización), ordenable, con — para entidades sin código (borradores). Cumpliendo las reglas del DS de tablas anchas (DESIGN.md): mismos valores fijos de toda tabla ancha del dashboard, columna Acciones sticky a la derecha intacta (con su sombra de separación y su skipClass en la medición de anchos), scroll horizontal en el contenedor — prohibido ajustar una tabla por su cuenta.
Todas las migraciones: aditivas, idempotentes y reversibles, con snapshot previo (Supabase sin PITR, prohibido DELETE sin snapshot). Numeración de migraciones desde 130_. Zonas de alto riesgo (migraciones + RLS) con manos Opus y verificación con evidencia (psql real, no reporte en prosa); las tareas mecánicas con Sonnet.
Waves:
| Wave | Tasks | Depende de | Parallelizable | Riesgo |
|---|---|---|---|---|
| 0 | 1, 2 | — | Sí (solo lectura + doc) | Bajo |
| 1 | 3, 4, 5 | Wave 0 (T3 requiere colisiones resueltas del T1) | Sí | ALTO — Opus (migraciones) |
| 2 | 6, 7, 8 | Wave 1 | Sí | Medio — Opus |
| 3 | 9, 10, 11, 12 | Wave 2 | Sí | Bajo-medio |
| 4 | 13 | Waves 1–3 | No (integración) | Medio — Opus |
| 5 | 14 | — (técnicamente independiente; va al final por tu decisión) | No | ALTO — Opus (migración eventos) |
Files: - Create: script de informe solo-lectura (queries de matching exacto/aproximado + colisiones duras) - Create: informe entregable para el equipo LPDI
Done when:
- [ ] Informe generado con grupos de duplicados (exactos y aproximados) y sección "colisiones duras" del índice nuevo
- [ ] Verificado con psql el estado real: pg_indexes de los índices únicos actuales + conteo de filas con status != 'DRAFT' (confirmar el hallazgo del índice muerto) + re-confirmación de que country está en nombre oficial (0 filas con char_length(country) <= 3 y country no vacío)
- [ ] Cero escrituras a la base: el script solo hace SELECT (revisión del código del script)
Files:
- Edit: DESIGN.md (regla "nada en borrador recibe código" del §5.6, con nota de que el codigo_unico de eventos se corrige en la Task 14 de este mismo plan — ya no es deuda)
Done when: - [ ] DESIGN.md contiene la regla transversal con la redacción del §5.6 y la referencia al fix de eventos como tarea del plan (no como deuda) - [ ] No se tocó ningún archivo de código (solo documentación)
Files:
- Create: supabase/migrations/130_dedup_unique_index_country.sql (+ rollback)
Done when: - [ ] Snapshot previo tomado y verificado (conteo de filas coincide) - [ ] Índice nuevo creado y viejo eliminado; probado con psql: inserción duplicada (mismo usuario + nombre + país, formalizada) falla con 23505; mismo nombre en país distinto pasa; "Peru"/"Perú" cuentan como el mismo país (unaccent) - [ ] Idempotencia probada: la migración corre dos veces sin error - [ ] Rollback probado en local: restaura índice viejo y no pierde datos
(Sin data-fix de países: verificado que country ya guarda el nombre oficial — tu punto 6.)
entity_join_requests + RPC tick (Wave 1) — ALTO RIESGO, manos OpusFiles:
- Create: supabase/migrations/131_entity_join_requests.sql (+ rollback)
Done when:
- [ ] Tabla con estados, token, expiración a 10 días y RLS deny-by-default (acceso vía service role, patrón icg_contacts)
- [ ] RPC de transición atómica probada con psql: dos aprobaciones concurrentes → solo una gana
- [ ] RPC tick (expiración + recordatorio día 5) probada con filas de prueba retro-fechadas y limpiadas después
- [ ] Idempotencia y rollback probados
Files:
- Create: supabase/migrations/132_entity_code.sql (+ rollback)
Done when:
- [ ] catalogo_codigo_tipo_entidad creada y sembrada con las 43 filas del §5.2; codigo_corto UNIQUE
- [ ] Columna entity_code (UNIQUE, nullable) en startups e icgs + tabla entidad_codigo_counters con PK (tipo, anio) + RPC next_entidad_consecutivo(p_tipo, p_anio) atómica; probado con psql: dos llamadas concurrentes al mismo (tipo, anio) devuelven números distintos, y las secuencias S e I avanzan por separado
- [ ] Backfill completo: 100% de entidades formalizadas con código en formato S-…/I-…, orden por formalized_at asc verificado con psql (muestra de 10 antiguas/nuevas); borradores en NULL
- [ ] CHECK de action_audit_log reescrito con los 5 action_types nuevos; INSERT de prueba de cada tipo pasa (y se limpia)
- [ ] Idempotencia y rollback probados
Files:
- Create: src/routes/api/entidades/similares/+server.ts (nombre final según convención del repo)
- Create: tests del enmascarado y del matching
Done when:
- [ ] Responde exactos + aproximados cruzando startups/ICGs con los campos de la tarjeta §3.2
- [ ] Correos enmascarados EN EL SERVIDOR (test: la respuesta JSON nunca contiene un correo completo) y rótulo "Admin en el sistema"
- [ ] Solo entidades formalizadas y no eliminadas; rate-limit/min-length para no ser un oráculo de scraping
- [ ] npm run test y npm run lint sin errores nuevos
Files:
- Create: src/lib/server/join-requests.ts + renders de correo (script de render separado, patrón del repo)
- Create: src/routes/api/cron/join-request-tick/+server.ts (patrón entity-transfer-tick, protegido por CRON_SECRET)
- Edit: vercel.json (cron nuevo — recordar: los crons de Vercel disparan por GET)
Done when:
- [ ] Los 5 correos de la matriz §4.4 se envían a las partes correctas (test por evento: creada→2, aprobada→2, rechazada→2, expirada→2, recordatorio→1)
- [ ] Aprobación crea la fila en startup_founders (con role + can_edit_form elegidos) o icg_contacts (con position), verificada
- [ ] Solicitante no verificado (Opción B) recibe 403; auditoría registrada en cada transición
- [ ] Tests pasan; render de correos generado y revisado visualmente
Files:
- Edit: src/lib/server/entities.ts (formalizeEntity) + helper de composición del código
- Create: tests del compositor (S/I + TTT/PPP/AA/NNNNN, fallbacks OTR/000, tildes)
Done when:
- [ ] Toda formalización (público y dashboard) asigna código exactamente una vez; idempotente (segunda llamada no regenera); startups reciben prefijo S- y secuencia S, ICGs prefijo I- y secuencia I
- [ ] Fallbacks probados: ICG sin icg_role → OTR; sin país → 000
- [ ] PPP replica el criterio de eventos con test que lo compara contra getPhonePrefix3, y el lookup del compositor es insensible a tildes: test explícito "Peru" (sin tilde) → 051 (hoy getPhonePrefix3('Peru') devuelve 000 — el helper lo corrige normalizando antes del lookup)
- [ ] Tests y lint sin errores nuevos
Files: - Create: componente de tarjetas de coincidencia (consumiendo el DS) - Edit: FSS/FSD/FIS/FID (los 4 shells de formulario)
Done when:
- [ ] Al salir del campo nombre con coincidencias, aparece el panel §3.2 en los 4 formularios (capturas Playwright de cada uno)
- [ ] "Crear de todas formas" sobre coincidencia exacta deja el rastro de auditoría (verificado en action_audit_log con psql)
- [ ] "Solicitar asociación" crea la solicitud y muestra la confirmación
- [ ] UI consume el DS (sin estilos nuevos fuera de tema); build sin errores
Files:
- Edit: /dashboard/perfil-startup y /dashboard/perfil-icg (sección de solicitudes pendientes + pantalla de decisión)
Done when: - [ ] El aprobador ve la tarjeta del solicitante y al aprobar asigna cargo + permisos (startup) o cargo (ICG) en la misma pantalla (capturas Playwright) - [ ] En ICG NO se ofrece permiso de edición (regla dura verificada en la UI y en el server) - [ ] Rechazo pide confirmación y notifica; estados vacíos correctos - [ ] Build y lint sin errores
Files:
- Edit: PS, PI y fichas de entidad (mostrar entity_code con acción copiar)
Done when: - [ ] Código visible en PS, PI y ficha; borradores muestran el estado "se asignará al formalizar" (capturas) - [ ] Sin regresiones visuales en los paneles (comparación antes/después)
Files:
- Edit: /dashboard/consulta/startups/+page.svelte (+ server) y /dashboard/consulta/icg/+page.svelte (+ server)
Done when:
- [ ] Columna "Código" tras el nombre, ordenable, — en vacíos, en ambas consultas (capturas Playwright)
- [ ] Reglas del DS intactas: Acciones sticky derecha con sombra y medición con skipClass sin romper (scroll horizontal verificado en viewport angosto)
- [ ] Build y lint sin errores
Done when:
- [ ] Flujo real completo en preview, sin caveats pendientes: registro con nombre duplicado → tarjeta → solicitar asociación → correos a ambas partes → aprobar con cargo+permisos → miembro verificado visible; y el camino "crear de todas formas" → auditoría + código asignado al formalizar
- [ ] Recordatorio día 5 y expiración día 10 probados con filas retro-fechadas (y limpiadas)
- [ ] Smoke test de las páginas tocadas + deploy verificado por commit SHA (no solo READY)
- [ ] Códigos en producción: muestra aleatoria de 10 entidades cumple el formato S-…/I-… y la unicidad (psql)
codigo_unico solo al enviar, nunca en borrador (Wave 5, la última — tu punto 5) — ALTO RIESGO, manos OpusCorrige la violación de la regla "nada en borrador recibe código" descrita en §5.6, con el diseño verificado archivo por archivo en la tabla de esa sección.
Files:
- Create: supabase/migrations/133_eventos_codigo_unico_on_submit.sql (+ rollback): ALTER TABLE eventos ALTER COLUMN codigo_unico DROP DEFAULT, DROP NOT NULL (la columna es SERIAL = NOT NULL + DEFAULT nextval; la secuencia se conserva); UPDATE eventos SET codigo_unico = NULL WHERE status = 'DRAFT' (snapshot previo); RPC atómica next_evento_codigo_unico() sobre la secuencia existente
- Edit: src/routes/registro-evento/+page.server.ts — el submit final asigna codigo_unico vía la RPC en el mismo punto donde hoy genera evento_codigo (una sola vez, idempotente: si la fila ya tiene código, no se regenera); el autosave (insert y update de borrador) no lo toca
- Edit: UI de compartir (EventRow / EventoDetailModal / eventoShare.ts según corresponda) — ocultar/deshabilitar compartir cuando codigoUnico es NULL (borradores)
Done when:
- [ ] Un borrador nuevo por autosave queda con codigo_unico NULL y la secuencia NO avanza (psql: last_value de la secuencia igual antes y después del autosave)
- [ ] El submit final asigna codigo_unico exactamente una vez (segunda invocación no lo cambia — probado); duplicar un evento produce copia en borrador SIN código
- [ ] Cero enlaces rotos: muestra con psql de 10 eventos APPROVED — su codigo_unico es idéntico antes y después de la migración, y curl a /eventos/{codigo} de 3 de ellos devuelve 200 en preview
- [ ] Los borradores en "Mis eventos" no ofrecen compartir (captura Playwright); un evento aprobado sí, con la URL correcta
- [ ] Idempotencia y rollback de la migración probados; super_eventos intacto (fuera de alcance, sin flujo de borrador)
NNNNNN (6). Este plan usa 5 (00042) — confirma que es 5, o se cambia a 6 antes de la migración (es un parámetro del compositor y del backfill; trivial antes de ejecutar, molesto después).Todo lo demás está decidido: año = registro en el sistema (tu 1), secuencias separadas S/I (tu 2), prefijo S/I (tu 3), correo de expiración a ambas partes (tu 4), fix del codigo_unico de eventos como Task 14 al final (tu 5), y sin data-fix de países (tu 6).
Resumen: el v3 cierra las 6 decisiones que tomaste sobre el v2. El código queda S-TTT-PPP-AA-NNNNN / I-TTT-PPP-AA-NNNNN con secuencias anuales independientes por tipo (contador atómico con clave tipo+año, patrón de eventos), año = registro en el sistema, y prefijo telefónico desde el SSOT COUNTRIES con lookup insensible a tildes. Desaparece el data-fix ISO2 (el supuesto era falso: el país ya está en nombre oficial en toda la data), el índice único por país se mantiene solo con la normalización unaccent existente, y el codigo_unico SERIAL de eventos entra como Task 14 en la última ola: se deja de asignar en el borrador del autosave y pasa al submit final, conservando intactos todos los números y enlaces públicos /eventos/{codigo} de los eventos ya publicados. 14 tareas en 6 olas, migraciones aditivas y reversibles con snapshot, alto riesgo con manos Opus y verificación con evidencia. Con tu OK final (y las dos confirmaciones menores de la §9) arrancamos por la Ola 0.