Royal Clean CRM · Documentación
Arquitectura

Almacenamiento de evidencia

Bucket privado, URLs firmadas, compresión y retención de las fotografías.

La evidencia fotográfica vive en Cloudflare R2, un almacenamiento de objetos compatible con el protocolo S3.

PostgreSQL guarda únicamente metadata: la clave del objeto, el tipo MIME, el tamaño, las dimensiones, a qué tarea pertenece y sus marcas de tiempo. Ninguna imagen se guarda en la base de datos ni en el disco del contenedor.

Por qué R2

  • No cobra por transferencia de salida, que es justo lo que más pesaría al mostrar galerías de fotografías.
  • Habla el protocolo S3, así que el mismo código funciona contra MinIO en desarrollo.
  • Guardar imágenes en PostgreSQL inflaría los respaldos hasta hacerlos inmanejables; guardarlas en el disco del contenedor las perdería en cada redespliegue.

El bucket es privado

No existe ninguna URL pública permanente. Cada visualización pasa por el servidor, que comprueba permisos y sólo entonces firma una URL de corta duración.

OperaciónVigencia de la firmaPor qué
Lectura10 minutosDe sobra para cargar una galería; una URL copiada por accidente en un chat deja de funcionar enseguida
Escritura15 minutosMás generosa: el personal sube desde redes móviles lentas

Flujo de subida

sequenceDiagram
    participant T as Teléfono
    participant A as Aplicación
    participant R as R2

    T->>T: 1. Comprimir, orientar, borrar EXIF
    T->>A: 2. requestUpload(servicio, tarea, tipo, tamaño)
    A->>A: 3. Autorizar, validar, generar la clave
    A->>A: 4. Crear Evidence en estado PENDING
    A->>R: 5. Firmar URL de subida
    A-->>T: 6. URL firmada + id de evidencia
    T->>R: 7. PUT directo del archivo
    T->>A: 8. confirmUpload(id)
    A->>R: 9. HEAD: ¿existe de verdad?
    A->>A: 10. Marcar CONFIRMED

El archivo no atraviesa la aplicación: va directo del teléfono a R2. Un servidor pequeño no se convierte en cuello de botella aunque suban veinte fotos a la vez.

Estados de la evidencia

EstadoSignifica
PENDINGSe firmó la subida, aún sin confirmar
CONFIRMEDEl objeto existe en R2 y es evidencia válida
DELETEDBorrado lógico por una persona; el objeto se eliminó de R2
EXPIREDSuperó la retención; el objeto se eliminó, la metadata permanece

Sólo CONFIRMED cuenta para completar una tarea. Si la subida falla a medias, la tarea sigue incompleta, que es lo correcto: nunca se marca algo como hecho antes de que el servidor lo confirme.

Compresión en el navegador

Una foto de móvil pesa entre 4 y 12 MB. Antes de subirla, el navegador:

  1. La decodifica aplicando la orientación EXIF, para que no salga girada.
  2. La redimensiona a 1600 px en el lado mayor.
  3. La vuelve a codificar en WebP con calidad 0,82.
  4. Si aún supera el límite, repite con calidad 0,65.

Resultado típico: 200–400 KB.

Volver a codificar en un canvas descarta todos los metadatos EXIF, incluidas las coordenadas GPS y el modelo del dispositivo. Royal Clean no rastrea a su personal, así que esos datos no deben ni salir del teléfono.

Implementado en src/lib/image-compression.ts.

Claves de objeto

Las genera siempre el servidor:

royal-clean/services/{serviceId}/tasks/{taskId}/{uuid}.webp
royal-clean/services/{serviceId}/incidents/{incidentId}/{uuid}.webp
royal-clean/services/{serviceId}/general/{uuid}.webp

El nombre del archivo del usuario nunca se usa como ruta: evita el path traversal, las colisiones y que datos personales acaben en el nombre del objeto. El UUID hace la clave impredecible.

Validaciones

QuéLímite
Entrada de imagen15 MB antes de comprimir
Subida de imagen3 MB (objetivo real: menos de 1 MB)
Subida de vídeo60 MB, sin transcodificar
Tipos aceptadosimage/webp, image/jpeg, video/mp4, video/quicktime, video/webm

El tipo y el tamaño van firmados en la URL de subida: cambiar cualquiera de los dos invalida la firma. Hay un test que lo comprueba subiendo con un Content-Type distinto del autorizado.

Los límites viven en src/modules/evidence/constants.ts, definidos una sola vez para el compresor del cliente, la validación del servidor y los mensajes de la interfaz.

Visualización

GET /api/evidence/{id}
  1. Busca la evidencia y su servicio.
  2. Llama a requireServiceAccess(). Si el usuario no tiene derecho, 404.
  3. Firma una URL de 10 minutos.
  4. Responde 302 hacia esa URL.

La redirección permite escribir <img src="/api/evidence/abc"> sin JavaScript, y hace que la URL firmada no quede en el marcado que el navegador guarda en el historial. La respuesta lleva Cache-Control: private, no-store.

Una evidencia borrada o expirada devuelve 410, para distinguirla de un id inválido.

Retención

Por defecto 12 meses, configurable. expiresAt se calcula al crear la evidencia.

El trabajo de mantenimiento:

  1. Busca evidencia CONFIRMED con expiresAt en el pasado.
  2. Borra los objetos de R2 por lotes.
  3. Marca como EXPIRED sólo las que se borraron de verdad; las que fallaron se reintentan en la siguiente ejecución.

El historial del servicio se conserva íntegro. Fechas, tareas, horas, incidentes y cifras siguen ahí; sólo desaparecen las fotografías.

Limpieza de subidas abandonadas

Una subida que se firma y nunca se confirma dejaría metadata huérfana y, posiblemente, un objeto pagando almacenamiento para siempre.

Pasados 60 minutos sin confirmar, el trabajo de mantenimiento borra el objeto —por si llegó a subirse— y elimina la fila.

Desarrollo local

docker compose levanta MinIO, que habla el mismo protocolo S3. El .env.example ya apunta ahí:

R2_ACCOUNT_ID="local"
R2_ACCESS_KEY_ID="royalclean"
R2_SECRET_ACCESS_KEY="royalclean_dev_secret"
R2_BUCKET_NAME="royal-clean-evidence"
R2_ENDPOINT="http://localhost:9000"

Consola web en http://localhost:9001.

Esto permite probar el flujo completo —firmar, subir, confirmar, expirar— sin una cuenta de Cloudflare, y es lo que ejecutan los tests de integración.

Direccionamiento por ruta

El cliente usa forcePathStyle: true. El estilo virtual por defecto del SDK construiría hosts como royal-clean-evidence.localhost, que no resuelve, y depende de comodines DNS que no controlamos en el endpoint de R2. Con direccionamiento por ruta, el mismo código funciona en ambos entornos.

Este defecto lo destapó un test de integración, no una revisión: ver DECISIONS.md DEC-018.

Configurar R2 en producción

  1. Cloudflare → R2 → Create bucketroyal-clean-evidence.
  2. Acceso público desactivado.
  3. Manage R2 API Tokens → Create API Token:
    • Permisos: Object Read & Write
    • Alcance: sólo ese bucket
  4. Guarda el Access Key ID y el Secret: el secreto no se vuelve a mostrar.
  5. Copia tu Account ID.

Variables en Dokploy: ver DOKPLOY.md.

Coste estimado

Con unos 20 servicios semanales, 5 fotos por servicio y 300 KB por foto:

  • ~30 GB al año antes de la retención
  • Con retención de 12 meses, el volumen se estabiliza
  • R2 no cobra salida, así que el coste es esencialmente el almacenamiento

Muy por debajo de lo que costaría el mismo tráfico en un proveedor que sí cobre transferencia.

On this page