La noche que me hizo escribir código

Una noche de verano, con calor, ventanas abiertas y durmiendo, empezaron los fuegos artificiales justo detrás de mi casa. Nadie me había avisado. Me levanté de golpe, corriendo por el pasillo a cerrar ventanas mientras mis gatos buscaban un hueco donde meterse. Los encontré escondidos donde no caben, muy asustados.

Lo que más me molestó no fue el ruido: fue que se podía haber evitado. El evento estaba autorizado, publicado en algún sitio, con fecha y hora. Alguien lo sabía. Yo no. Y con veinte minutos de aviso habría cerrado la casa y estado con ellos antes del primer estruendo.

De ahí salió patabum.es: avisos por correo de eventos pirotécnicos cerca de tu casa, para animales de dos y de cuatro patas — porque los animales son la parte visible, pero esto también va de personas mayores, bebés, gente con TEA, entre otros. Ahora mismo funciona como proyecto piloto en la isla de Gran Canaria, con los 21 municipios dados de alta, y la idea es seguir ampliando cobertura si hay demanda. Es gratis y no hace falta crear ninguna cuenta.

Este post se va a centrar un poco en la parte técnica.

El problema real no es la búsqueda geoespacial

Lo obvio: "avísame de lo que vaya a sonar a menos de 3 km de mi casa". Suena a ejercicio de manual — un punto, un radio, una consulta.

Lo incómodo: para responder a eso necesito saber dónde vive la gente. Una base de datos con la dirección aproximada de miles de personas y su correo electrónico es, con diferencia, lo más sensible.

Así que el diseño se hizo al revés de lo habitual. Primero: ¿cuál es la mínima información que puedo guardar para que el producto siga funcionando? Después ya vemos cómo se consulta.

PostGIS, y por qué no calcular distancias a mano

La tentación en PHP es traerse las suscripciones y filtrar con la ecuación de Haversine en memoria. Funciona con cien filas y se cae solo cuando crecen, porque no hay índice que aproveche: cada evento publicado obliga a recorrer la tabla entera.

En PostgreSQL con PostGIS la suscripción guarda una columna geography y el filtro lo hace la base de datos con un índice espacial:

Subscription::query()
    ->verified()
    ->active()
    ->selectRaw(
        'subscriptions.*, ST_Distance(location, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography) / 1000 AS distance_km',
        [$lng, $lat]
    )
    ->whereRaw(
        'ST_DWithin(location, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, radius_meters)',
        [$lng, $lat]
    )
    ->get();

Dos detalles que me parecen los que de verdad importan aquí:

  • geography y no geometry. Con geography las distancias salen en metros sobre el elipsoide y no hay que reproyectar nada ni pelearse con grados. Se paga algo de rendimiento, y a esta escala da igual.

  • El radio vive en la fila, no en la consulta. Fíjate en que el tercer argumento de ST_DWithin es radius_meters, una columna. Cada persona elige su distancia (de 1 a 10 km), así que la pregunta no es "¿quién está a menos de 3 km del evento?" sino "¿para quién cae este evento dentro del radio que esa persona pidió?". Es la misma consulta y es un producto distinto.

Todo esto ocurre además fuera de la petición web: cuando se publica un evento, un observer encola un job que hace el cruce y agrupa los avisos. La búsqueda pública nunca dispara procesos pesados.

La parte que quería contar: guardar menos

Aquí está lo que hace este proyecto distinto.

1. La ubicación se cuantiza antes de tocar la base de datos

Nunca se guarda el punto exacto que marcó el usuario en el mapa. Antes de escribir, las coordenadas se redondean a una rejilla de 0,0025°, unos 250 metros de lado a la altura del punto seleccionado:

public const LOCATION_GRID_DEGREES = 0.0025;

public static function snapToGrid(float $degrees): float
{
    return round($degrees / self::LOCATION_GRID_DEGREES) * self::LOCATION_GRID_DEGREES;
}

Se redondea, no se trunca, para no introducir un sesgo sistemático hacia una esquina. El desplazamiento máximo de un punto es media diagonal de la celda: unos 185 metros.

Y va en el evento saving() del modelo, no en el controlador:

static::saving(function (Subscription $subscription) {
    if ($subscription->latitude !== null) {
        $subscription->latitude = self::snapToGrid((float) $subscription->latitude);
    }
    // ...
});

Esa decisión es la mitad del valor. Puesto en el controlador, lo cumple la vía que alguien se acordó de proteger. Puesto en el modelo, lo cumplen el formulario, los seeders, las factories, el panel de administración y cualquier cosa que yo escriba dentro de seis meses cuando ya no me acuerde de esto.

¿Y no empeora el producto? No, los eventos se geocodifican con incertidumbres de 300 a 600 metros, así que la rejilla del suscriptor normalmente caerá dentro del ruido de los propios datos del evento. El radio más pequeño que se puede elegir son 1.000 metros.

2. El correo, cifrado en reposo y buscable por índice ciego

El correo electrónico se guarda cifrado, con el cast de Laravel:

protected function casts(): array
{
    return [
        'email' => 'encrypted',
        // ...
    ];
}

El cifrado de Laravel no es determinista: dos filas con el mismo correo no se parecen en nada. Perfecto para la confidencialidad y un desastre para consultar, porque where('email', ...) deja de existir. La respuesta clásica es un índice ciego: una columna con un HMAC del correo normalizado, atada a la clave de la aplicación.

public static function hashEmail(string $email): string
{
    return hash_hmac('sha256', self::normalizeEmail($email), (string) config('app.key'));
}

Con eso puedo responder "¿existe ya una suscripción con este correo?" y agrupar todas las zonas de una misma persona, sin guardar la dirección en claro ni poder recorrer la tabla al revés. Es HMAC y no un sha256 pelado por una razón concreta: sin clave, cualquiera con la tabla puede generar hashes de un diccionario de correos y comprobar si estás dentro.

Va con una consecuencia que hay que asumir en voz alta: APP_KEY pasa a ser tan crítica como la propia base de datos. Si se pierde, no se pierde solo la capacidad de descifrar: el índice ciego deja de encontrar nada y las suscripciones quedan huérfanas.

3. Lo que no existe no se filtra

No hay cuentas de usuario. No hay contraseñas que hashear, ni recuperación de contraseña que abusar, ni sesiones que robar. Te suscribes con tu correo, confirmas con doble opt-in y gestionas o cancelas tus zonas desde un enlace firmado que lleva el hash, nunca la dirección. Un enlace filtrado no revela a quién pertenece.

Y por si hace falta decirlo claro: nada de esto me saca del RGPD. El correo sigue siendo un dato personal y sigue habiendo un responsable con sus obligaciones. Lo que consigue todo junto es otra cosa, más modesta y más útil: que un volcado robado de la base no sea una lista de direcciones de casa.

Lo que no cuento aquí

La otra mitad del proyecto es la ingestión: recoger publicaciones oficiales, detectar cuáles hablan de pirotecnia, extraer fecha, hora y lugar, geocodificar y publicar con revisión humana antes de que salga ningún correo. Va todo en colas y con validación previa — la búsqueda pública nunca ejecuta scraping ni IA en tiempo real. Esa parte da para su propia publicación.

Si quieres verlo funcionando o probarlo con tu casa, está en patabum.es, como decía, de momento como piloto en Gran Canaria.