General

Reconocimiento Facial — Documentación Técnica

**Fecha:** Noviembre 2026 (actualizado)

Reconocimiento Facial — Documentación Técnica

Sistema "Ver Personas" — Postea.la

Fecha: Noviembre 2026 (actualizado)
Estado: ✅ Completamente en producción
Autor: Equipo Postea.la
Planes: Profesional y Platinum

✅ En producción (Septiembre 2026)

  • Pipeline completo: IndexFaces al subir cada foto (DetectFaces eliminado — redundante)
  • Overlay de caras en photo-detail con nombres y navegación
  • PersonPhotosComponent: página por invitado rediseñada — header centrado, tabs con badge dorado, grid 3 columnas
  • PersonsListComponent: lista de personas con tabs Identificadas/Sin identificar, avatares con crop de cara via CSS bbox
  • event_face_persons para persistir personId por evento (ya no se usa name como identificador)
  • Saludos en tabla comments con content_type = 'PERSON'
  • face_search_cache eliminada (diseño defectuoso — datos obsoletos durante eventos activos)
  • Conteo de fotos con COUNT(DISTINCT photo_id) y filtro is_active=1
  • Perfil de persona basado en BD — no llama a Rekognition en GET /persons/{faceId}
  • totalPersons incluido en el response de GET /persons/{faceId}
  • Collation utf8mb4_0900_ai_ci en event_face_persons — fix para evitar error en JOIN con photos

✅ Cambios Nov 2026

  • personId reemplaza a name como identificador de persona — toda cara nueva recibe un FacePerson vacío al indexarse, con personId asignado inmediatamente. Ya no se usa name != null para determinar si una persona es "conocida".
  • FacePerson tabla nueva — almacena perfil de la persona (nombre, cargo, empresa, ciudad, LinkedIn, Instagram). Se crea vacía y se llena cuando el invitado edita su perfil.
  • event_face_persons.person_id — referencia a face_persons.id. Antes tenía campo name inline.
  • Deduplicación al indexar — SearchFaces corre dentro de indexFacesInPhoto. Si la cara es similar a una ya registrada en el evento, se reutiliza el mismo personId en vez de crear uno nuevo.
  • SearchFaces se salta en la primera foto — si totalFacesIndexed == 0 antes de esta foto, no hay similares posibles.
  • DetectFaces eliminado — era redundante. IndexFaces ya devuelve bounding boxes en FaceRecord.face().boundingBox().
  • Todos los logs de pago marcados con 💰 REKOGNITION_PAID — para auditoría y control de costos.
  • deletePhoto desindexa de Rekognition — al borrar una foto, los faceIds que quedan sin fotos se eliminan de la colección AWS con DeleteFaces, evitando matches fantasma.

1. Arquitectura General

Stack tecnológico

Componente Tecnología
Detección de rostros AWS Rekognition DetectFaces
Indexación AWS Rekognition IndexFaces
Búsqueda / similitud AWS Rekognition SearchFacesByImage / SearchFaces
Almacenamiento imágenes AWS S3 us-east-1 (bucket bucketvideosposteala-us)
Rekognition region us-east-1 — mismo bucket para evitar cross-region
Base de datos MariaDB (local Docker) / MariaDB (EC2 producción)
Backend Spring Boot 3 + @Async + CompletableFuture

Diagrama de flujo — subida de foto

Foto subida por invitado
        │
        ▼
AsyncMediaProcessingService.processOriginalAsync()
        │
        ├── [IA moderación activa] ──→ RekognitionService.analyzeImage()
        │         └── [REJECTED] ──→ STOP (no indexar)
        │
        ├── Generar thumbnail + compressed
        │
        └── [faceRecognitionEnabled=true]
                    │
                    ▼
            FaceRecognitionService.indexFacesInPhoto()
                    │
                    ├── 💰 IndexFaces → registra faceIds en colección, devuelve bbox
                    ├── Guarda en photo_faces (faceId + bbox)
                    └── [si totalFacesIndexed > 0 antes de esta foto]
                                │
                                └── 💰 SearchFaces por cada cara nueva → similarsByFaceId
                    │
                    ▼
            AsyncMediaProcessingService (por cada cara)
                    ├── ¿Ya existe EventFacePerson para este faceId? → skip
                    ├── ¿Hay similar con personId en BD? → reusar personId
                    └── No: crear FacePerson vacío + EventFacePerson nuevo

Diagrama de flujo — identificar a una persona

Usuario edita perfil (PUT /personas/{personId})
        │
        ▼
Actualiza face_persons (nombre, cargo, empresa, ciudad, linkedin, instagram)
        │
        └── Todos los faceIds del mismo personId ya están vinculados
            via event_face_persons — no requiere propagación adicional

2. Modelo de Datos

2.1 Cambios en tablas existentes

events

ALTER TABLE events ADD COLUMN face_recognition_enabled BOOLEAN NOT NULL DEFAULT FALSE;

photos

ALTER TABLE photos
  ADD COLUMN faces_indexed BOOLEAN NOT NULL DEFAULT FALSE,
  ADD COLUMN faces_count   INT     NOT NULL DEFAULT 0;

comments

El campo content_type es VARCHAR(255). Se agregó el valor 'PERSON' para saludos a invitados.
No requiere migración — es varchar, no enum.


2.2 Nuevas tablas

event_face_collections

Una colección Rekognition por evento. Se crea al activar el feature.

CREATE TABLE event_face_collections (
  id                          BIGINT PRIMARY KEY AUTO_INCREMENT,
  event_id                    BIGINT NOT NULL UNIQUE,
  rekognition_collection_id   VARCHAR(100) NOT NULL,  -- "postea-{eventSlug}"
  is_active                   BOOLEAN NOT NULL DEFAULT TRUE,
  total_faces_indexed         INT NOT NULL DEFAULT 0,
  created_at                  TIMESTAMP NOT NULL,
  deleted_at                  TIMESTAMP NULL
);

photo_faces

Cada rostro detectado en cada foto. Almacena bounding box para overlay en frontend.

CREATE TABLE photo_faces (
  id                    BIGINT PRIMARY KEY AUTO_INCREMENT,
  photo_id              BIGINT NOT NULL,
  event_id              BIGINT NOT NULL,
  rekognition_face_id   VARCHAR(100) NOT NULL,
  confidence            FLOAT NOT NULL,
  bbox_left             FLOAT NOT NULL,   -- valores 0.0–1.0
  bbox_top              FLOAT NOT NULL,
  bbox_width            FLOAT NOT NULL,
  bbox_height           FLOAT NOT NULL,
  created_at            TIMESTAMP NOT NULL,
  INDEX idx_photo_faces_photo_id (photo_id),
  INDEX idx_photo_faces_event_id (event_id),
  INDEX idx_photo_faces_face_id  (rekognition_face_id)
);

event_face_persons

Mapea rekognition_face_id → person_id por evento. Cada cara indexada tiene exactamente un registro. Múltiples caras de la misma persona apuntan al mismo person_id.

CREATE TABLE event_face_persons (
  id                    BIGINT PRIMARY KEY AUTO_INCREMENT,
  event_id              BIGINT NOT NULL,
  rekognition_face_id   VARCHAR(100) NOT NULL,
  person_id             BIGINT NULL,          -- FK a face_persons.id
  created_at            DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uq_event_face (event_id, rekognition_face_id),
  INDEX idx_efp_event_id (event_id),
  INDEX idx_efp_face_id  (rekognition_face_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

face_persons (nueva — Nov 2026)

Perfil de una persona en un evento. Se crea vacío al indexar la primera cara y se llena cuando el invitado edita su perfil.

CREATE TABLE face_persons (
  id              BIGINT PRIMARY KEY AUTO_INCREMENT,
  event_id        BIGINT NOT NULL,
  name            VARCHAR(100) NULL,
  job_title       VARCHAR(100) NULL,
  company         VARCHAR(100) NULL,
  city            VARCHAR(100) NULL,
  linkedin_url    VARCHAR(255) NULL,
  instagram_url   VARCHAR(255) NULL,
  profile_photo_url VARCHAR(500) NULL,
  created_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_fp_event_id (event_id)
);

Modelo de identidad (Nov 2026):

  • 1 persona → 1 FacePerson con personId
  • 1 persona en N fotos → N faceIds distintos de Rekognition
  • N EventFacePerson apuntan al mismo personId
  • Para identificar si una cara es "conocida": verificar si hay EventFacePerson con personId que tenga similar en el evento — NO usar name != null

3. Entidades JPA

EventFaceCollection

@Entity @Table(name = "event_face_collections")
public class EventFaceCollection {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
    @Column(name = "event_id", nullable = false, unique = true) private Long eventId;
    @Column(name = "rekognition_collection_id") private String rekognitionCollectionId;
    @Column(name = "is_active") private Boolean isActive = true;
    @Column(name = "total_faces_indexed") private Integer totalFacesIndexed = 0;
    @Column(name = "created_at") private Instant createdAt;
    @Column(name = "deleted_at") private Instant deletedAt;
}

PhotoFace

@Entity @Table(name = "photo_faces")
public class PhotoFace {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
    @Column(name = "photo_id") private Long photoId;
    @Column(name = "event_id") private Long eventId;
    @Column(name = "rekognition_face_id") private String rekognitionFaceId;
    @Column(name = "confidence") private Float confidence;
    @Column(name = "bbox_left")   private Float bboxLeft;
    @Column(name = "bbox_top")    private Float bboxTop;
    @Column(name = "bbox_width")  private Float bboxWidth;
    @Column(name = "bbox_height") private Float bboxHeight;
    @Column(name = "created_at")  private Instant createdAt;
}

EventFacePerson

@Entity @Table(name = "event_face_persons")
public class EventFacePerson {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
    @Column(name = "event_id") private Long eventId;
    @Column(name = "rekognition_face_id") private String rekognitionFaceId;
    @Column(name = "name") private String name;
    @Column(name = "named_at") private Instant namedAt;
    @Column(name = "named_by") private String namedBy;
    @Column(name = "profile_photo_id") private Long profilePhotoId;
    @Column(name = "created_at") private Instant createdAt;
    @PrePersist void prePersist() { if (createdAt == null) createdAt = Instant.now(); }
}

4. Servicios

4.1 FaceRecognitionService

Métodos públicos

// Crear colección al activar el feature en un evento
EventFaceCollection createCollectionForEvent(String eventSlug, Long eventId);

// Indexar rostros de una foto recién subida
FaceIndexResult indexFacesInPhoto(Long photoId, String s3Key, Long eventId);

// Buscar fotos por imagen subida (selfie)
List<Long> searchByUpload(byte[] imageBytes, String eventSlug, Long eventId);

// Buscar fotos por faceId seleccionado — devuelve photoIds de esa persona
List<Long> searchByFaceId(String faceId, String eventSlug, Long eventId);

// Devuelve solo los faceIds similares (para propagación de nombres)
List<String> searchSimilarFaceIds(String faceId, String eventSlug, Long eventId);

// Bounding boxes de una foto (para overlay frontend)
List<PhotoFaceDTO> getFacesInPhoto(Long photoId);

// Borrar colección al vencer el evento
void deleteCollectionForEvent(String eventSlug);

FaceIndexResult

public record FaceIndexResult(Long photoId, int facesIndexed, List<PhotoFace> faces) {}

Parámetros Rekognition

Parámetro Valor Motivo
MIN_FACE_CONFIDENCE 80% Balance precisión/recall
FACE_MATCH_THRESHOLD 80% Agrupa misma persona en distintas fotos
MAX_FACES_PER_PHOTO 10 Límite de caras por foto
MAX_SEARCH_FACES 10 Resultados de búsqueda
QualityFilter (IndexFaces) AUTO Rekognition descarta caras borrosas

4.2 AsyncMediaProcessingService — hook de indexación

if (Boolean.TRUE.equals(event.getFaceRecognitionEnabled())) {
    FaceIndexResult faceResult = faceRecognitionService.indexFacesInPhoto(photoId, s3Key, event.getId());
    photo.setFacesIndexed(true);
    photo.setFacesCount(faceResult.facesIndexed());
    photoRepository.save(photo);

    // Auto-nombrar: solo si ya hay personas nombradas (evita llamadas innecesarias a Rekognition)
    long namedCount = eventFacePersonRepository.countByEventIdAndNameIsNotNull(event.getId());
    if (namedCount > 0) {
        for (PhotoFace pf : faceResult.faces()) {
            List<String> similarFaceIds = faceRecognitionService.searchSimilarFaceIds(
                    pf.getRekognitionFaceId(), eventSlug, event.getId());
            for (String similarFaceId : similarFaceIds) {
                // Si el faceId similar tiene nombre, copiarlo al nuevo faceId
                eventFacePersonRepository
                    .findByEventIdAndRekognitionFaceId(event.getId(), similarFaceId)
                    .filter(e -> e.getName() != null)
                    .ifPresent(named -> {
                        // Guardar solo si el nuevo faceId aún no tiene registro
                        eventFacePersonRepository.findByEventIdAndRekognitionFaceId(
                                event.getId(), pf.getRekognitionFaceId())
                            .ifPresentOrElse(
                                existing -> { /* ya existe, no sobreescribir */ },
                                () -> eventFacePersonRepository.save(EventFacePerson.builder()
                                    .eventId(event.getId())
                                    .rekognitionFaceId(pf.getRekognitionFaceId())
                                    .name(named.getName())
                                    .namedBy(named.getNamedBy())
                                    .namedAt(Instant.now())
                                    .build())
                            );
                    });
            }
        }
    }
}

s3Key se extrae con s3Service.extractKeyFromUrl(originalUrl) — no hardcodeado.


5. Endpoints REST

Base path: /api/events/{slug}/faces

POST   /search/select
       Body: { faceId: String, searcherName?: String }
       → Busca fotos por faceId
       → Response: FaceSearchResultDTO

GET    /photos/{photoId}
       → Bounding boxes + nombre (si identificado) de cada cara en la foto
       → Response: List<PhotoFaceResponseDTO>

GET    /persons
       → Lista de todas las personas del evento agrupadas por nombre
       → Response: { identified: PersonSummaryDTO[], unidentified: PersonSummaryDTO[], totalPersons: int }
       → Cada PersonSummaryDTO incluye: faceId, name, profilePhotoUrl, photoCount, bboxLeft/Top/Width/Height

GET    /persons/{faceId}
       → Perfil del invitado: nombre, fotos donde aparece (vía BD, no Rekognition)
       → Agrupa todos los faceIds con el mismo nombre para obtener todas las fotos
       → Incluye totalPersons (COUNT DISTINCT faceIds del evento)
       → Response: PersonProfileDTO

PUT    /persons/{faceId}/name
       Body: { name: String, namedBy?: String }
       → Guarda nombre y propaga a faceIds similares vía SearchFaces
       → Response: { faceId, name, namedBy, alreadyNamed }

GET    /persons/{faceId}/comments
       → Saludos del invitado (content_type='PERSON')
       → Response: List<{ id, message, authorName, createdAt }>

POST   /persons/{faceId}/comments
       Body: { message: String, authorName?: String }
       → Agrega saludo. Auto-crea EventFacePerson si no existe.
       → Response: { id, message, authorName, createdAt }

POST   /collection
       → Crea la colección Rekognition del evento
       → Response: { collectionId, eventId, active }

DELETE /collection
       → Borra la colección Rekognition del evento
       → Response: 204 No Content

DTOs principales

// Perfil de invitado
public class PersonProfileDTO {
    private Long personId;
    private String faceId;
    private String name;
    private String namedBy;
    private Instant namedAt;
    private String profilePhotoUrl;
    private List<PhotoMiniDTO> photos;
    private Integer totalPersons;  // COUNT DISTINCT faceIds del evento

    public record PhotoMiniDTO(Long photoId, String thumbnailUrl, String compressedUrl) {}
}

// Resumen de persona en lista
public class PersonSummaryDTO {
    private String faceId;
    private String name;
    private String profilePhotoUrl;
    private int photoCount;
    private Float bboxLeft, bboxTop, bboxWidth, bboxHeight;  // para crop de cara en avatar
}

// Cara en foto (para overlay)
public class PhotoFaceResponseDTO {
    private String faceId;
    private Float confidence;
    private Float bboxLeft, bboxTop, bboxWidth, bboxHeight;
    private String personName;  // null si no identificado
}

6. Frontend

Rutas

/event/:eventId/personas           →  PersonsListComponent
/event/:eventId/persona/:faceId    →  PersonPhotosComponent

PersonsListComponent

  • Tabs: Identificadas / Sin identificar con contadores
  • Avatar circular con crop de cara CSS usando bboxLeft/Top/Width/Height:
    faceStyle(p: PersonSummary): object {
      const pad = 0.30;
      const scale = 1 / (p.bboxWidth + pad * 2);
      const offsetX = -(p.bboxLeft - pad) * scale * 100;
      const offsetY = -(p.bboxTop - pad) * scale * 100;
      return {
        position: 'absolute',
        width: `${scale * 100}%`,
        top: `${offsetY}%`,
        left: `${offsetX}%`
      };
    }
    
  • Navega a PersonPhotosComponent pasando totalPersons via router state
  • CTA "¿Falta alguien?" al fondo → cambia activeTab = 'unidentified'

PersonPhotosComponent

  • Header centrado: avatar 88px, nombre en Playfair Display, botón ✏️, pill dorado "Ver todas las personas [N]"
  • totalPersons se lee de profile.totalPersons (viene del backend), fallback a history.state
  • Panel edición nombre: separado del header, con input full-width + botones Guardar/Cancelar
  • Tabs: Fotos (N) / Saludos (N) — activo con underline dorado #d4a574 y badge dorado
  • Grid fotos: 3 columnas cuadradas aspect-ratio: 1, gap 2px, paginación de 10 en 10
  • Overlay "+N" en última celda del grid si hay más fotos sin cargar
  • Saludos: input integrado como pill con botón enviar circular dorado
  • Fondo header: #faf7f4 (mismo que la página de personas)
  • "Invitado desconocido" en letra normal (sin cursiva)

FaceRecognitionService (frontend)

getPersonsListForEvent(eventSlug): Observable<PersonsListResponse>
getPersonProfile(eventSlug, faceId): Observable<PersonProfile>       // incluye totalPersons
namePerson(eventSlug, faceId, name, namedBy?): Observable<{name, namedBy, alreadyNamed}>
getPersonComments(eventSlug, faceId): Observable<PersonComment[]>
addPersonComment(eventSlug, faceId, message, authorName): Observable<PersonComment>
getFacesInPhoto(eventSlug, photoId): Observable<PhotoFace[]>

Interfaces clave:

export interface PersonProfile {
  personId: number | null;
  faceId: string;
  name: string | null;
  namedBy: string | null;
  namedAt: string | null;
  profilePhotoUrl: string | null;
  photos: { photoId: number; thumbnailUrl: string; compressedUrl: string }[];
  totalPersons: number | null;
}

export interface PersonSummary {
  faceId: string;
  name: string | null;
  profilePhotoUrl: string | null;
  photoCount: number;
  bboxLeft: number | null;
  bboxTop: number | null;
  bboxWidth: number | null;
  bboxHeight: number | null;
}

7. Repositories

// EventFacePersonRepository
Optional<EventFacePerson> findByEventIdAndRekognitionFaceId(Long eventId, String faceId);
List<EventFacePerson> findByEventId(Long eventId);
long countByEventIdAndNameIsNotNull(Long eventId);

// PhotoFaceRepository
List<PhotoFace> findByPhotoId(Long photoId);
List<Long> findPhotoIdsByFaceIdAndEventId(String faceId, Long eventId);
List<Long> findPhotoIdsByFaceIdsAndEventId(List<String> faceIds, Long eventId);
List<PhotoFace> findByPhotoIdInAndEventId(List<Long> photoIds, Long eventId);
int countDistinctFaceIdsByEventId(Long eventId);  // para totalPersons en perfil

// Queries nativas (performance crítica)
// Conteo de fotos por faceId (para lista de personas)
@Query("SELECT pf.rekognitionFaceId, COUNT(DISTINCT pf.photoId) FROM PhotoFace ...")
List<Object[]> countPhotosByFaceIdForEvent(Long eventId);

// Conteo de fotos ÚNICO por nombre (evita doble conteo multi-faceId)
@Query(nativeQuery=true, """
    SELECT efp.name, COUNT(DISTINCT pf.photo_id)
    FROM event_face_persons efp
    JOIN photo_faces pf ON pf.rekognition_face_id = efp.rekognition_face_id AND pf.event_id = efp.event_id
    JOIN photos p ON p.id = pf.photo_id AND p.is_active = 1
    WHERE efp.event_id = :eventId AND efp.name IS NOT NULL
    GROUP BY efp.name
""")
List<Object[]> countDistinctPhotosByNameForEvent(Long eventId);

// Bounding box de primera foto por faceId (para avatares con crop)
@Query("SELECT pf.rekognitionFaceId, pf.bboxLeft, pf.bboxTop, pf.bboxWidth, pf.bboxHeight ...")
List<Object[]> findBboxByFaceIdsAndEventId(List<String> faceIds, Long eventId);

8. Configuración AWS

Naming convention de colecciones

postea-{eventSlug}
Ejemplo: postea-boda-cesar-2026

Bucket S3

bucketvideosposteala-us  (us-east-1)
BucketOwnerPreferred — ACLs habilitados

9. Costos AWS — Estimación por evento

Escenario: 60 fotos, promedio 3 rostros/foto, 20 búsquedas

API Llamadas Precio unitario Total (USD)
DetectFaces 60 $0.001 eliminado
IndexFaces 60 $0.001 $0.060
SearchFaces (dedup al indexar) ~120* $0.001 $0.120
SearchFacesByImage (find-me) 20 $0.001 $0.020
SearchFaces (by-face desde visor) ~30 $0.001 $0.030
S3 transferencia ~180MB $0.09/GB $0.016
Total ~$0.25

*SearchFaces al indexar: 1 por cara nueva, solo si ya hay caras previas en la colección. Primera foto = 0 SearchFaces. Con 3 caras/foto: foto 1 → 0, foto 2 → 3, foto 3 → 3... ≈ 2 por foto promedio.

Con Profesional (S/79 ≈ $21 USD): costo AWS = ~1.2% del ingreso por evento (antes 1.4% con DetectFaces).

Ver detalles: documentacion-funcional/rekognition_costos.md


10. Estado de implementación

✅ Completado en producción (Nov 2026)

BD
├── ✅ events.face_recognition_enabled
├── ✅ photos.faces_indexed, photos.faces_count
├── ✅ event_face_collections
├── ✅ photo_faces
├── ✅ event_face_persons  (person_id en vez de name — Nov 2026)
├── ✅ face_persons         (perfil editable — Nov 2026)
└── ❌ face_search_cache   ← eliminada (diseño defectuoso)

Backend
├── ✅ FaceRecognitionService:
│     ├── indexFacesInPhoto (sin DetectFaces — Nov 2026)
│     ├── SearchFaces dentro de indexFacesInPhoto (Nov 2026)
│     ├── deleteFacesFromCollection al borrar foto (Nov 2026)
│     ├── searchByFaceId, searchByUpload, findMeByUpload
│     └── todos los logs 💰 REKOGNITION_PAID (Nov 2026)
├── ✅ AsyncMediaProcessingService:
│     ├── indexación al subir foto
│     └── dedup por personId (sin llamadas extra a Rekognition — Nov 2026)
├── ✅ AdminPhotoService: deletePhoto limpia Rekognition (Nov 2026)
└── ✅ FaceRecognitionController:
      ├── GET /persons — lista con personId
      ├── GET /persons/{personId} — perfil
      ├── PUT /persons/{personId} — editar perfil (nombre, cargo, empresa, ciudad, linkedin, instagram)
      ├── GET /persons/by-face/{faceId} — resolver faceId → personId
      ├── GET|POST /persons/{personId}/comments
      └── POST|DELETE /collection

Frontend
├── ✅ FaceRecognitionService (face-recognition.service.ts)
├── ✅ photo-detail: overlay de caras, navegación a persona por personId
├── ✅ PersonsListComponent: tabs Identificadas/Sin identificar, avatares con crop
├── ✅ PersonPhotosComponent: header, tabs fotos/saludos, grid 3 col
└── ✅ ProPersonProfileEditComponent: form compacto con prefijos inline (Nov 2026)
      ├── Solo handle en LinkedIn (linkedin.com/in/tu-perfil)
      └── Solo @usuario en Instagram

⚠️ Optimizaciones pendientes

- SearchFaces en by-face: si personId ya está en BD, devolver fotos sin ir a Rekognition
  (ahorra 1 llamada por cada click en cara desde el visor)
- Umbral mínimo de caras antes de hacer SearchFaces al indexar
  (si evento < 5 fotos, prob. de match muy baja)

🚧 Deuda técnica

- Tests unitarios e integración para el sistema de faces
- searchSimilarFaceIds() aún hace llamada extra a Rekognition —
  solo se usa desde contextos donde ya tenemos los similares del índex

11. Consideraciones de seguridad

  • Los endpoints de personas son públicos — cualquier persona con el slug del evento puede acceder. Intencional: los invitados no tienen cuenta.
  • Los faceId de Rekognition son identificadores opacos — no contienen datos biométricos exportables.
  • Al vencer el evento, deleteCollectionForEvent borra la colección en AWS y los registros en BD.
  • Todos los filtros de BD van por event_id — no hay cruce de datos entre eventos.
  • Cada evento tiene su propia colección Rekognition aislada (postea-{slug}).