diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 5c6b42f..0000000 --- a/.dockerignore +++ /dev/null @@ -1,15 +0,0 @@ -# Contexto de build de la imagen web. El builder hace su propio -# `meteor npm install`; nada del host debe colarse. -.git -**/.git -.meteor/local -node_modules -smoke/node_modules -smoke/snapshots -plan-modernizacion -secrets -docker-compose.yml -RUNBOOK.md -*.log -.env -.env.* diff --git a/.forgejo/workflows/build-image.yml b/.forgejo/workflows/build-image.yml deleted file mode 100644 index 0ff52b1..0000000 --- a/.forgejo/workflows/build-image.yml +++ /dev/null @@ -1,207 +0,0 @@ -# Construye la imagen Docker de la web y la sube al registry de Forgejo. -# -# POR QUÉ: antes el build corría en groucho, el host de despliegue (5,9 GB de RAM -# compartidos con Mongo 7 + web + notifications + redis + node-red). Un `meteor build` -# lo asfixiaba hasta dejarlo sin ssh. A partir de aquí groucho **no compila**: hace -# `docker compose pull && up -d web` y ya. -# -# runs-on: docker → el runner de aaron (forge.yml, label `docker`, capacity 1). -# -# ⚠️ POR QUÉ KANIKO Y NO `docker build`: -# aaron es un host COMPARTIDO — aloja git.comunes.org, Jenkins y GlitchTip. Construir con -# `docker build` exige montar /var/run/docker.sock en el job, que es poder -# root-equivalente sobre aaron; y además el límite de memoria del contenedor del job NO -# afectaría al build, porque quien construye es el demonio, fuera del job. Kaniko -# construye DENTRO del contenedor del job: no hace falta socket y `--memory` sí acota al -# proceso que se come la RAM. Si el `meteor build` muere por memoria, sube el `--memory` -# de `container.options` (y mira qué más está corriendo en aaron), no lo quites. - -# -# ⚠️ POR QUÉ EL TEST VIVE EN ESTE FICHERO Y NO EN UNO APARTE: -# `needs:` solo enlaza jobs DEL MISMO workflow — no hay forma de que un fichero -# distinto bloquee a este. Y el requisito de la fase 11 es justo ese: que una -# imagen no se publique nunca con los tests en rojo. Así que los dos jobs van -# juntos, `build` depende de `test`, y se acabó. Si algún día act_runner soporta -# `workflow_call` de forma fiable, se puede extraer. - -name: build-image - -on: - push: - branches: [meteor3-wip, tcef-master] - paths-ignore: ['**.md'] - pull_request: - workflow_dispatch: - -jobs: - # Suite de servidor (meteortesting:mocha). Bloquea el build: ver la nota de - # arriba. Mide y publica duración y pico de RAM en el resumen del job para que - # el margen del runner de aaron sea un dato y no una intuición. - test: - runs-on: docker - container: - image: node:22-bookworm - # Medido en local: pico ~2,0 GB (meteor test + su mongod). 3g deja margen - # sin comerse los 4g que necesita el build de Kaniko justo después - # (capacity: 1 ⇒ los jobs no coinciden en el tiempo). Si el job muere por - # OOM, sube esto y mira antes qué más está corriendo en aaron. - options: --memory=3g --memory-swap=3g --cpus=3 - env: - # meteor se niega a arrancar como root si no. - METEOR_ALLOW_SUPERUSER: '1' - # Node 22 + Happy Eyeballs elige IPv6 sin ruta contra warehouse.meteor.com - # (mismo workaround que en el Dockerfile y en dev). - NODE_OPTIONS: --dns-result-order=ipv4first --no-network-family-autoselection - METEOR_RELEASE: '3.1' - steps: - # `time` para medir el pico de RSS; el resto es lo mismo que instala el - # Dockerfile (node-gyp 3.x de algunas deps de Atmosphere llama a `python`). - - name: Herramientas - run: | - set -e - apt-get update -qq - apt-get install -y --no-install-recommends \ - curl ca-certificates git time python3 python-is-python3 make g++ >/dev/null - rm -rf /var/lib/apt/lists/* - - # Sin actions JS: el código se baja por el API de Forgejo, igual que en el - # job de build (ver la nota de más abajo). - - name: Descargar el código - env: - TOKEN: ${{ github.token }} - run: | - set -e - mkdir -p src - wget -q -O src.tar.gz \ - "http://x-access-token:${TOKEN}@forgejo:3000/api/v1/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz" - tar xzf src.tar.gz -C src --strip-components=1 - rm -f src.tar.gz - test -f src/package.json || { echo "No hay package.json en el tarball"; ls src | head; exit 1; } - - # ~700 MB de meteor-tool en cada job. No se cachea porque `actions/cache` es - # una action JS (fase 9 §3) y montar un volumen del host exige tocar - # `valid_volumes` en la config de act_runner de aaron. Pendiente: si el job - # se vuelve molesto, añadir /data/ci-cache/meteor -> /root/.meteor. - - name: Instalar meteor-tool - run: | - set -e - curl -fsSL "https://install.meteor.com/?release=${METEOR_RELEASE}" | sh - meteor --version - - - name: Dependencias npm - run: | - set -e - cd src - meteor npm ci --no-audit --no-fund - - # `meteor test` levanta su propio mongod (single-node replica set), que es - # lo que hace que el oplog esté disponible y los observadores reaccionen al - # instante: contra un mongo externo sin oplog, Meteor cae a polling cada 10s - # y los tests de subsUnion tardarían eso en ver cada cambio. - - name: Tests de servidor - run: | - set -e - cd src - START=$(date +%s) - /usr/bin/time -v -o ../time.log meteor npm run test:ci - echo "DURACION=$(( $(date +%s) - START ))s" - grep -E 'Maximum resident set size' ../time.log || true - - - name: Resumen - if: always() - run: | - { - echo "### Tests de servidor" - echo "" - echo "- pico de RSS: $(grep -oE '[0-9]+$' time.log 2>/dev/null | head -1 || echo '?') KB" - } >> "$GITHUB_STEP_SUMMARY" || true - - build: - # Ninguna imagen sale de aquí con los tests en rojo. Ese es el punto entero - # de la fase 11. - needs: test - # En un PR solo interesa saber si pasa; publicar imagen es cosa de la rama. - if: github.event_name != 'pull_request' - runs-on: docker - container: - # `:debug` trae busybox: el runner necesita una shell para ejecutar los `run:`. - image: gcr.io/kaniko-project/executor:v1.23.2-debug - # Tope duro para no asfixiar aaron. memory-swap == memory ⇒ swap 0: preferimos que - # muera el build a que el host empiece a paginar y se lleve por delante la forja. - options: --memory=4g --memory-swap=4g --cpus=3 - env: - IMAGE: git.comunes.org/comunes/tcef-web - steps: - # Primero de todo: sin credenciales el build tarda ~30 min en llegar al push para - # morir ahí. Mejor fallar en 2 segundos y decir exactamente qué falta. - - name: Comprobar secretos - shell: sh - env: - REGISTRY_USER: ${{ secrets.REGISTRY_USER }} - REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - export PATH=/busybox:/kaniko:$PATH - if [ -z "$REGISTRY_USER" ] || [ -z "$REGISTRY_TOKEN" ]; then - echo "Faltan los secretos REGISTRY_USER / REGISTRY_TOKEN." - echo "Crealos en Settings -> Actions -> Secrets de este repositorio." - echo "REGISTRY_TOKEN: token de git.comunes.org con scope write:package" - echo "(Settings -> Applications -> Generate New Token). No reutilices uno con scope 'all'." - exit 1 - fi - - # Nada de `actions/checkout` (necesita Node) ni de `git` (la imagen de kaniko solo trae - # busybox y los binarios de kaniko: no hay git, ni apk para instalarlo). Se baja el - # tarball del commit por el API de Forgejo, que busybox sí sabe hacer con wget + tar. - - name: Descargar el código - shell: sh - env: - TOKEN: ${{ github.token }} - run: | - set -e - export PATH=/busybox:/kaniko:$PATH - mkdir -p src - wget -q -O src.tar.gz \ - "http://x-access-token:${TOKEN}@forgejo:3000/api/v1/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz" - tar xzf src.tar.gz -C src --strip-components=1 - rm -f src.tar.gz - test -f src/Dockerfile || { echo "No hay Dockerfile en el tarball"; ls src | head; exit 1; } - - - name: Credenciales del registry - shell: sh - env: - REGISTRY_USER: ${{ secrets.REGISTRY_USER }} - REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -e - export PATH=/busybox:/kaniko:$PATH - mkdir -p /kaniko/.docker - AUTH=$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_TOKEN" | base64 | tr -d '\n') - printf '{"auths":{"git.comunes.org":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json - - - name: Build y push - shell: sh - run: | - set -e - export PATH=/busybox:/kaniko:$PATH - SHORT_SHA=$(echo "$GITHUB_SHA" | cut -c1-12) - BRANCH_TAG=$(echo "$GITHUB_REF_NAME" | tr '/' '-') - # --cache: reutiliza capas entre builds (apt, meteor-tool, npm install) contra - # el propio registry; sin esto cada push recompila el mundo. - # --single-snapshot y --compressed-caching=false bajan mucho el pico de RAM, - # que es justo lo que nos importa en un host compartido. - /kaniko/executor \ - --context "dir://$(pwd)/src" \ - --dockerfile Dockerfile \ - --destination "${IMAGE}:${BRANCH_TAG}" \ - --destination "${IMAGE}:${SHORT_SHA}" \ - --cache=true \ - --cache-repo "${IMAGE}/cache" \ - --compressed-caching=false \ - --snapshot-mode=redo \ - --verbosity=info - echo "Publicado ${IMAGE}:${BRANCH_TAG} y :${SHORT_SHA}" - - - name: Cómo desplegar - shell: sh - run: | - echo "En groucho: cd /data/tcef-staging && docker compose pull web && docker compose up -d web" diff --git a/.forgejo/workflows/e2e.yml b/.forgejo/workflows/e2e.yml deleted file mode 100644 index dc3f766..0000000 --- a/.forgejo/workflows/e2e.yml +++ /dev/null @@ -1,129 +0,0 @@ -# Suite de navegador (Playwright) + smoke REST contra un stack EFÍMERO. -# -# POR QUÉ NOCTURNO Y NO EN CADA PUSH: esto construye el bundle de producción -# entero (`meteor build`, lo mismo que hace la imagen) y además baja un Chromium. -# aaron es un host compartido con `capacity: 1`: si esto corriera en cada push, -# la forja se quedaría sin turno. Los tests de servidor (build-image.yml) sí van -# en cada push y son los que bloquean la imagen. -# -# POR QUÉ NO USA docker compose: el runner no tiene socket de Docker (ver la nota -# de build-image.yml) — así que en vez de levantar docker-compose.e2e.yml, este -# job construye el bundle y lo arranca dentro de su propio contenedor, con Mongo -# como service container. El compose sigue siendo la forma de correrlo en local. -# -# ⚠️ Las dos siembras (smoke/seed.js y e2e/seed.js) BORRAN colecciones. Aquí eso -# es seguro porque el Mongo es un service container que muere con el job. Jamás -# apuntes esto a testfuegos.comunes.org. - -name: e2e - -on: - schedule: - # 04:15 UTC, con el runner libre. - - cron: '15 4 * * *' - workflow_dispatch: - -jobs: - e2e: - runs-on: docker - container: - image: node:22-bookworm - # El pico es el `meteor build`, igual que en el job de la imagen. - options: --memory=4g --memory-swap=4g --cpus=3 - services: - mongo: - image: mongo:7 - # Nodo suelto, sin replica set: los service containers no dejan cambiar - # el comando de arranque. Sin oplog Meteor cae a polling (cada 10s), así - # que los tests que esperan a la unión tardan más — por eso sus timeouts - # son de 60s. En local (docker-compose.e2e.yml) sí hay replica set. - options: >- - --health-cmd "mongosh --quiet --eval 'db.adminCommand({ping:1}).ok'" - --health-interval 5s --health-timeout 5s --health-retries 20 - env: - METEOR_ALLOW_SUPERUSER: '1' - NODE_OPTIONS: --dns-result-order=ipv4first --no-network-family-autoselection - METEOR_RELEASE: '3.1' - MONGO_URL: mongodb://mongo:27017/fuegos - ROOT_URL: http://localhost:3000 - PORT: '3000' - BASE_URL: http://localhost:3000 - E2E_BASE_URL: http://localhost:3000 - SETTINGS: settings-ci.json - steps: - - name: Herramientas - run: | - set -e - apt-get update -qq - apt-get install -y --no-install-recommends \ - curl ca-certificates git python3 python-is-python3 make g++ >/dev/null - # mongosh por npm: el cliente no viene en node:22 y el service container - # de mongo no es accesible con `docker exec` desde el job. - npm install -g mongosh@2 >/dev/null - - - name: Descargar el código - env: - TOKEN: ${{ github.token }} - run: | - set -e - mkdir -p src - wget -q -O src.tar.gz \ - "http://x-access-token:${TOKEN}@forgejo:3000/api/v1/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz" - tar xzf src.tar.gz -C src --strip-components=1 - rm -f src.tar.gz - - - name: Instalar meteor-tool - run: curl -fsSL "https://install.meteor.com/?release=${METEOR_RELEASE}" | sh - - - name: Construir el bundle de producción - run: | - set -e - cd src - meteor npm ci --no-audit --no-fund - meteor build /tmp/build --directory --server-only - cd /tmp/build/bundle/programs/server && npm install --omit=dev --no-audit --no-fund - - - name: Arrancar el servidor - run: | - set -e - cd /tmp/build/bundle - METEOR_SETTINGS="$(cat "${GITHUB_WORKSPACE}/src/settings-ci.json")" \ - node main.js > /tmp/server.log 2>&1 & - for i in $(seq 1 90); do - wget -qO /dev/null "http://127.0.0.1:3000/" && break - sleep 2 - done - wget -qO /dev/null "http://127.0.0.1:3000/" || { echo "El servidor no arrancó:"; tail -50 /tmp/server.log; exit 1; } - - # El smoke va primero y con su propia siembra: compara respuestas byte a - # byte contra snapshots, así que cualquier dato que dejen los e2e (una - # suscripción, la unión recalculada) lo haría fallar. - - name: Smoke REST - run: | - set -e - cd src - mongosh --host mongo --quiet fuegos smoke/seed.js - node smoke/run.js - - - name: Suite e2e - run: | - set -e - cd src - mongosh --host mongo --quiet fuegos e2e/seed.js - # Dentro de e2e/: playwright busca su playwright.config.js en el - # directorio de trabajo, y `npx --prefix` resuelve el binario pero NO - # cambia el cwd (correría sin config y sin encontrar los tests). - cd e2e - npm ci --no-audit --no-fund - npx playwright install --with-deps chromium - npx playwright test - - # Sin `actions/upload-artifact` (action JS, ver fase 9 §3): al menos el log - # del servidor y la lista de trazas quedan en la salida del job. - - name: Diagnóstico en caso de fallo - if: failure() - run: | - echo "===== últimas 200 líneas del servidor =====" - tail -200 /tmp/server.log || true - echo "===== trazas de Playwright =====" - ls -R src/e2e/test-results 2>/dev/null | head -50 || true diff --git a/.gitignore b/.gitignore index b48695c..603fb12 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,3 @@ output.json settings-production-*.json settings-tests.json *.json~ - -# fase-3: secretos del stack docker-compose local (solo se versionan los .example) -secrets/* -!secrets/README.md -!secrets/*.example diff --git a/.meteor/.finished-upgraders b/.meteor/.finished-upgraders index c07b6ff..910574c 100644 --- a/.meteor/.finished-upgraders +++ b/.meteor/.finished-upgraders @@ -15,5 +15,3 @@ notices-for-facebook-graph-api-2 1.4.1-add-shell-server-package 1.4.3-split-account-service-packages 1.5-add-dynamic-import-package -1.7-split-underscore-from-meteor-base -1.8.3-split-jquery-from-blaze diff --git a/.meteor/packages b/.meteor/packages index 4516001..5cca156 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -4,56 +4,66 @@ # 'meteor add' and 'meteor remove' will edit this file for you, # but you can also edit it by hand. -meteor-base@1.5.2 # Packages every Meteor app needs to have -mobile-experience@1.1.2 # Packages for a great mobile UX -mongo@2.0.3 # The database Meteor supports right now -reactive-var@1.0.13 # Reactive variable for tracker -tracker@1.3.4 # Meteor's client-side reactive programming library -session@1.2.2 # Reactive Session var, required by themeteorchef:bert's Bert.alert() +meteor-base@1.3.0 # Packages every Meteor app needs to have +mobile-experience@1.0.5 # Packages for a great mobile UX +mongo@1.4.7 # The database Meteor supports right now +reactive-var@1.0.11 # Reactive variable for tracker +tracker@1.1.3 # Meteor's client-side reactive programming library -standard-minifier-css@1.9.3 # CSS minifier run for production mode -standard-minifier-js@3.0.0 # JS minifier run for production mode -ecmascript@0.16.10 # Enable ECMAScript2015+ syntax in app code -shell-server@0.6.1 # Server-side component of the `meteor shell` command +standard-minifier-css@1.4.0 # CSS minifier run for production mode +standard-minifier-js@2.3.1 # JS minifier run for production mode +es5-shim@4.7.0 # ECMAScript 5 compatibility for older browsers. +ecmascript@0.10.6 # Enable ECMAScript2015+ syntax in app code +shell-server@0.3.1 # Server-side component of the `meteor shell` command -react-meteor-data@3.0.6 -fourseven:scss@5.0.0 -accounts-base@3.0.3 -accounts-password@3.0.3 -service-configuration@1.3.5 -accounts-facebook@1.3.4 -accounts-github@1.5.1 -accounts-google@1.4.1 +react-meteor-data +alanning:roles +fourseven:scss +accounts-base@1.4.2 +accounts-password@1.5.0 +service-configuration@1.0.11 +accounts-facebook@1.3.1 +accounts-github@1.4.1 +accounts-google@1.3.1 themeteorchef:bert fortawesome:fontawesome -aldeed:collection2@4.0.4 -audit-argument-checks@1.0.8 -ddp-rate-limiter@1.2.2 -dynamic-import@0.7.4 -static-html@1.4.0 +aldeed:collection2-core@2.0.1 +audit-argument-checks@1.0.7 +ddp-rate-limiter@1.0.7 +dynamic-import@0.3.0 +static-html +alexwine:bootstrap-4 +selaias:cookie-consent # Cookie consent gadicc:blaze-react-component # Add braze to react 255kb:meteor-status # Connect status # mixmax:smart-disconnect # Disconnect on lost focus (disabled to get notif) +flowkey:raven # js errors vjrj:piwik # Stats mdg:geolocation -tcef:publish-performant-counts +natestrauser:publish-performant-counts +maximum:server-transform +mys:fonts # fonst in npm ostrio:mailer # mailer # babrahams:constellation # dev utility percolate:migrations # db migrations ostrio:meteor-root +meteortesting:mocha +practicalmeteor:chai aldeed:schema-deny +aldeed:template-extension dburles:collection-helpers +less@2.7.11 +markdown@1.0.12 +mongo-livedata@1.0.12 reywood:publish-composite -# Bootstrap CSS/JS now comes from the `bootstrap` npm package (BS5), imported in -# imports/startup/client/index.js. The old alexwine:bootstrap-4 package (BS4 CSS + -# jQuery + BS4 JS) was removed after the navbar/carousel/dropdown/feedback widgets -# were migrated off jQuery. -facts-base@1.0.2 +barbatus:stars-rating +arkham:comments-ui +facts@1.0.9 +gadicohen:sitemaps nspangler:autoreconnect -quave:synced-cron +saucecode:timezoned-synced-cron # lmachens:kadira -nimble:restivus@0.8.12 -underscore@1.6.4 - -meteortesting:mocha # test driver (see `npm test` / `npm run test-watch`) +meteorhacks:zones +nimble:restivus +xolvio:cleaner diff --git a/.meteor/release b/.meteor/release index 8d20e1a..011385b 100644 --- a/.meteor/release +++ b/.meteor/release @@ -1 +1 @@ -METEOR@3.1 +METEOR@1.6.1.1 diff --git a/.meteor/versions b/.meteor/versions index 506bd9a..9cbf1c3 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -1,126 +1,155 @@ 255kb:meteor-status@1.5.0 -accounts-base@3.0.3 -accounts-facebook@1.3.4 -accounts-github@1.5.1 -accounts-google@1.4.1 -accounts-oauth@1.4.5 -accounts-password@3.0.3 -aldeed:collection2@4.2.0 -aldeed:schema-deny@4.0.2 -aldeed:simple-schema@1.13.1 -allow-deny@2.0.0 -audit-argument-checks@1.0.8 -autoupdate@2.0.0 -babel-compiler@7.11.2 -babel-runtime@1.5.2 -base64@1.0.13 -binary-heap@1.0.12 -blaze@3.0.3 -blaze-tools@2.0.1 -boilerplate-generator@2.0.0 -caching-compiler@2.0.1 -caching-html-compiler@2.0.1 -callback-hook@1.6.0 -check@1.4.4 -core-runtime@1.0.0 -dburles:collection-helpers@1.0.0 -ddp@1.4.2 -ddp-client@3.0.3 -ddp-common@1.4.4 -ddp-rate-limiter@1.2.2 -ddp-server@3.0.3 -diff-sequence@1.1.3 -dynamic-import@0.7.4 -ecmascript@0.16.10 -ecmascript-runtime@0.8.3 -ecmascript-runtime-client@0.12.2 -ecmascript-runtime-server@0.11.1 -ejson@1.1.4 -email@3.1.1 -es5-shim@4.8.1 -facebook-oauth@1.11.4 -facts-base@1.0.2 -fetch@0.1.5 +accounts-base@1.4.2 +accounts-facebook@1.3.1 +accounts-github@1.4.1 +accounts-google@1.3.1 +accounts-oauth@1.1.15 +accounts-password@1.5.1 +alanning:roles@1.2.16 +aldeed:collection2-core@2.1.2 +aldeed:schema-deny@2.0.1 +aldeed:template-extension@4.1.0 +alexwine:bootstrap-4@4.1.0 +allow-deny@1.1.0 +arkham:comments-ui@1.4.4 +audit-argument-checks@1.0.7 +autoupdate@1.4.0 +babel-compiler@7.0.7 +babel-runtime@1.2.2 +barbatus:stars-rating@1.1.1 +base64@1.0.11 +binary-heap@1.0.10 +blaze@2.3.2 +blaze-tools@1.0.10 +boilerplate-generator@1.4.0 +browser-policy@1.1.0 +browser-policy-common@1.0.11 +browser-policy-content@1.1.0 +browser-policy-framing@1.1.0 +caching-compiler@1.1.11 +caching-html-compiler@1.1.2 +callback-hook@1.1.0 +check@1.3.1 +coffeescript@1.0.17 +dburles:collection-helpers@1.1.0 +dburles:mongo-collection-instances@0.3.5 +ddp@1.4.0 +ddp-client@2.3.2 +ddp-common@1.4.0 +ddp-rate-limiter@1.0.7 +ddp-server@2.1.2 +deps@1.0.12 +diff-sequence@1.1.0 +dynamic-import@0.3.0 +ecmascript@0.10.7 +ecmascript-runtime@0.5.0 +ecmascript-runtime-client@0.6.2 +ecmascript-runtime-server@0.5.0 +ejson@1.1.0 +email@1.2.3 +es5-shim@4.7.3 +facebook-oauth@1.4.0 +facts@1.0.9 +flowkey:raven@1.1.0 fortawesome:fontawesome@4.7.0 -fourseven:scss@5.0.0 -gadicc:blaze-react-component@2.0.1 -geojson-utils@1.0.12 -github-oauth@1.4.2 -google-oauth@1.4.5 -hot-code-push@1.0.5 -html-tools@2.0.1 -htmljs@2.0.2 -id-map@1.2.0 -inter-process-messaging@0.1.2 +fourseven:scss@4.5.4 +gadicc:blaze-react-component@1.4.0 +gadicohen:robots-txt@0.0.10 +gadicohen:sitemaps@0.0.26 +geojson-utils@1.0.10 +github-oauth@1.2.0 +google-oauth@1.2.5 +hot-code-push@1.0.4 +html-tools@1.0.11 +htmljs@1.0.11 +http@1.4.0 +id-map@1.1.0 jquery@1.11.11 -launch-screen@2.0.1 -localstorage@1.2.1 -logging@1.3.5 +lai:collection-extensions@0.2.1_1 +launch-screen@1.1.1 +less@2.7.12 +livedata@1.0.18 +localstorage@1.2.0 +logging@1.1.20 +markdown@1.0.12 +maximum:package-base@1.1.2 +maximum:server-transform@0.5.0 mdg:geolocation@1.3.0 -meteor@2.0.2 -meteor-base@1.5.2 -meteortesting:browser-tests@1.8.0 -meteortesting:mocha@3.3.0 -meteortesting:mocha-core@8.2.0 -minifier-css@2.0.0 -minifier-js@3.0.1 -minimongo@2.0.2 -mobile-experience@1.1.2 -mobile-status-bar@1.1.1 -modern-browsers@0.1.11 -modules@0.20.3 -modules-runtime@0.13.2 -mongo@2.0.3 -mongo-decimal@0.2.0 -mongo-dev-server@1.1.1 -mongo-id@1.0.9 -mongo-livedata@1.0.13 +meteor@1.8.6 +meteor-base@1.3.0 +meteorhacks:inject-initial@1.0.4 +meteorhacks:zones@1.6.0 +meteortesting:browser-tests@0.2.0 +meteortesting:mocha@0.5.1 +minifier-css@1.3.1 +minifier-js@2.3.4 +minimongo@1.4.4 +mobile-experience@1.0.5 +mobile-status-bar@1.0.14 +modules@0.11.6 +modules-runtime@0.9.2 +mongo@1.4.7 +mongo-dev-server@1.1.0 +mongo-id@1.0.7 +mongo-livedata@1.0.12 +mys:fonts@0.0.2 +natestrauser:publish-performant-counts@0.1.2 nimble:restivus@0.8.12 -npm-mongo@6.10.0 +npm-bcrypt@0.9.3 +npm-mongo@2.2.34 nspangler:autoreconnect@0.0.1 -oauth@3.0.0 -oauth2@1.3.3 -observe-sequence@2.0.1 -ordered-dict@1.2.0 -ostrio:mailer@2.5.0 +oauth@1.2.3 +oauth2@1.2.0 +observe-sequence@1.0.16 +ordered-dict@1.1.0 +ostrio:cookies@2.1.3 +ostrio:mailer@2.1.0 ostrio:meteor-root@1.0.7 -percolate:migrations@2.0.1 -promise@1.0.0 -quave:synced-cron@2.4.0 -raix:eventemitter@1.0.0 -random@1.2.2 -rate-limit@1.1.2 -react-fast-refresh@0.2.9 -react-meteor-data@3.0.6 -reactive-dict@1.3.2 -reactive-var@1.0.13 -reload@1.3.2 -retry@1.1.1 +peerlibrary:assert@0.2.5 +peerlibrary:fiber-utils@0.6.0 +peerlibrary:reactive-mongo@0.1.1 +peerlibrary:server-autorun@0.5.2 +percolate:migrations@1.0.2 +practicalmeteor:chai@2.1.0_1 +practicalmeteor:mocha-core@1.0.1 +promise@0.10.2 +raix:eventemitter@0.1.3 +random@1.1.0 +rate-limit@1.0.9 +react-meteor-data@0.2.16 +reactive-dict@1.2.0 +reactive-var@1.0.11 +reload@1.2.0 +retry@1.1.0 reywood:publish-composite@1.6.0 -routepolicy@1.1.2 -service-configuration@1.3.5 -session@1.2.2 -sha@1.0.10 -shell-server@0.6.1 -simple:json-routes@3.0.0 -socket-stream-client@0.5.3 -spacebars@2.0.1 -spacebars-compiler@2.0.1 -standard-minifier-css@1.9.3 -standard-minifier-js@3.0.0 -static-html@1.4.0 -static-html-tools@1.0.0 -tcef:publish-performant-counts@0.2.0 -templating@1.4.4 -templating-compiler@2.0.1 -templating-runtime@2.0.2 -templating-tools@2.0.1 -themeteorchef:bert@1.1.0 -tracker@1.3.4 -typescript@5.6.3 -underscore@1.6.4 -url@1.3.5 +routepolicy@1.0.13 +saucecode:timezoned-synced-cron@1.2.11 +selaias:cookie-consent@0.4.0 +server-render@0.3.0 +service-configuration@1.0.11 +session@1.1.7 +sha@1.0.9 +shell-server@0.3.1 +shim-common@0.1.0 +simple:json-routes@2.1.0 +socket-stream-client@0.1.0 +spacebars@1.0.15 +spacebars-compiler@1.1.3 +srp@1.0.10 +standard-minifier-css@1.4.1 +standard-minifier-js@2.3.3 +static-html@1.2.2 +templating@1.3.2 +templating-compiler@1.3.3 +templating-runtime@1.3.2 +templating-tools@1.1.2 +themeteorchef:bert@2.1.3 +tmeasday:check-npm-versions@0.3.2 +tracker@1.1.3 +ui@1.0.13 +underscore@1.0.10 +url@1.2.0 vjrj:piwik@0.3.1 -webapp@2.0.4 -webapp-hashing@1.1.2 -zodern:types@1.0.13 +webapp@1.5.0 +webapp-hashing@1.0.9 +xolvio:cleaner@0.3.3 diff --git a/.meteorignore b/.meteorignore index 83c45df..b25101c 100644 --- a/.meteorignore +++ b/.meteorignore @@ -1,11 +1,3 @@ # Don't reload meteor server when we modify or do test cucumber output.json -# REST smoke-test harness runs standalone (Node), never as Meteor server code -smoke -# Ops/deploy scripts (p.ej. scripts/*.mongo.js usan el global `db` de mongosh, no de Meteor) -scripts -# Suite Playwright: es un proyecto Node aparte (e2e/package.json), no codigo del servidor -e2e -# Banco de carga (fase 12): scripts Node sueltos, no codigo del servidor -bench diff --git a/.npmrc b/.npmrc deleted file mode 100644 index d3d0113..0000000 --- a/.npmrc +++ /dev/null @@ -1,5 +0,0 @@ -# Las libs react-* antiguas (react-bootstrap 0.31, react-confirm 0.1.16, ...) -# declaran peers de react 15/16 pero funcionan con react 18 (ver UPGRADE.md). -# npm >= 7 fallaría con ERESOLVE sin esto — aplica igual en dev y en el build -# de la imagen Docker. -legacy-peer-deps=true diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index e8176ea..0000000 --- a/Dockerfile +++ /dev/null @@ -1,47 +0,0 @@ -# Web Meteor 3.1 — imagen de producción (fase 3, Docker Compose). -# -# Multi-stage: -# 1. meteor-builder (debian/glibc): instala el meteor-tool y construye el bundle. -# 2. server-deps (alpine/musl): npm install de programs/server con toolchain -# nativa, sobre la MISMA libc que el runtime. -# 3. runtime (node:22-alpine): solo el bundle listo. -# -# Secretos: NUNCA en la imagen. El entrypoint carga METEOR_SETTINGS desde el -# fichero apuntado por METEOR_SETTINGS_FILE (montado por compose). - -FROM node:22-bookworm AS meteor-builder -ENV METEOR_ALLOW_SUPERUSER=1 -# Node 22 + Happy Eyeballs elige IPv6 sin ruta contra warehouse.meteor.com -# (mismo workaround que en dev, ver UPGRADE.md). -ENV NODE_OPTIONS="--dns-result-order=ipv4first --no-network-family-autoselection" -# python-is-python3: node-gyp 3.x (pulled by some Atmosphere npm deps) invokes -# `python`, absent on bookworm by default. -RUN apt-get update \ - && apt-get install -y --no-install-recommends curl ca-certificates python3 python-is-python3 make g++ git \ - && rm -rf /var/lib/apt/lists/* -RUN curl -fsSL "https://install.meteor.com/?release=3.1" | sh -WORKDIR /app -COPY . . -RUN meteor npm install -RUN meteor build /build --directory --server-only - -FROM node:22-alpine AS server-deps -RUN apk add --no-cache python3 make g++ -WORKDIR /bundle -COPY --from=meteor-builder /build/bundle . -RUN cd programs/server && npm install --omit=dev - -FROM node:22-alpine -ENV NODE_ENV=production PORT=3000 -WORKDIR /app -COPY --from=server-deps --chown=node:node /bundle . -COPY --chown=node:node docker/web-entrypoint.sh /web-entrypoint.sh -RUN chmod +x /web-entrypoint.sh -USER node -EXPOSE 3000 -# 127.0.0.1 (not localhost): busybox wget resolves localhost to ::1 first, but -# the Meteor server listens on IPv4 only -> the check would false-negative. -HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=5 \ - CMD wget -qO /dev/null "http://127.0.0.1:${PORT}/" || exit 1 -ENTRYPOINT ["/web-entrypoint.sh"] -CMD ["node", "main.js"] diff --git a/PENDIENTE.md b/PENDIENTE.md deleted file mode 100644 index 144e6a7..0000000 --- a/PENDIENTE.md +++ /dev/null @@ -1,129 +0,0 @@ -# Pendiente — web "Todos contra el fuego" (rama `meteor3-wip`) - -Estado a 2026-07-18. La migración **Meteor 1.6 → 3.1 + MongoDB 7** está completa -y verificada (server async, React 18, web renderiza, smoke REST byte-idéntico). -Lo que falta, agrupado. Ver `UPGRADE.md` para el detalle de lo ya hecho. - ---- - -## 1. Modernización de librerías react-* antiguas (deuda de front grande) - -Funcionan en React 18 por compatibilidad heredada, pero ensucian la consola en -desarrollo (no en producción) y **bloquean el salto a React 19**. Estado: - -| Lib | De → A | Estado | -|---|---|---| -| react-helmet | 5.2 → react-helmet-async 2 | ✅ hecho | -| react-i18next + i18next | 7.4/10.5 → 14/23 | ✅ hecho | -| react-bootstrap | 0.31 → 2 | ✅ hecho (CSS sigue en Bootstrap 4) | -| reactstrap + 3 deps muertas | — | ✅ eliminadas (0 usos) | -| react-router-dom | 4.2 → 6.30 | ✅ hecho (history 5, HOC `withRouterCompat`) | -| react-leaflet + leaflet | 1.8/1.3 → 4.2/1.9 | ✅ hecho (Map→MapContainer, refs directas, useMap/useMapEvents; 4 plugins reimplementados: MapControl portal, GoogleMutantLayer, fullscreen, sleep/graphicscale vanilla; ver UPGRADE.md) | -| **Bootstrap CSS/JS** | 4.1 → 5 | ⏸️ **diferida** (carrusel/navbar jQuery BS4; ver UPGRADE.md) | - -Lo verificado en navegador tras cada lib: `/`, `/fires`, `/fire/archive/`, -`/login`, `/subscriptions` + consola. Smoke REST byte-idéntico en cada paso. -Limpieza de warnings restantes YA hecha: `defaultProps` en componentes función → -parámetros por defecto; `UNSAFE_componentWillReceiveProps` (FromNow, FireStats, -Fires, SelectionMap) → `getDerivedStateFromProps`/`componentDidUpdate`; Reconnect -(banner Blaze `meteorStatus` con `findDOMNode`) → React nativo con -`useTracker(Meteor.status)`; react-share 2→5 y react-progress-bar.js → progressbar.js -(ambos tiraban `findDOMNode`/`defaultProps`); y react-leaflet 1.8→4.2 (mapa central, -4 plugins reimplementados). **Tras esto la consola tiene 0 warnings de React en las -rutas principales** (verificado forzando re-render en / y /fires). Único resto para -React 19: el `` de Status.js (`findDOMNode`), solo en `/status` (admin). - ---- - -## 2. Deuda funcional / de calidad de la web (independiente de prod) - -- **Comentarios (feature React nueva): verificación manual en staging.** El smoke - no llega a la UI. Los **métodos** (insert/edit/remove/like/dislike) y el embed - (`mediaAnalyzers`) ya están cubiertos por mocha (`test/server/comments.test.js`), - pero quedan por probar a mano en una página de fuego: - - publicar / editar / borrar un comentario (UI React) - - like / dislike (toggle) - - render del embed de imagen y de YouTube - - email a otros comentaristas (`onCommentAdd.js`, entrega real de correo) -- ✅ **`FireContainer` endurecido** (`imports/ui/pages/Fires/Fires.js`): el - `findOne()` sin selector ahora se acota por el `_id` de la URL en la ruta - `archive` (`new Meteor.Collection.ObjectID(id)`); en active/alert/hash cada - publicación deja un único fire en minimongo, así que el read vacío es correcto. -- ✅ **Suite de tests con runner (`meteortesting:mocha`).** `npm test` corre - `meteor test … --driver-package meteortesting:mocha` (watch: `npm run test-watch`). - Tests reescritos a `chai` + APIs async de Meteor 3 en `test/server/*.test.js` - (server-only), incluyendo cobertura nueva de los métodos de comentarios. Jest y - `rest.test.js` (ya cubierto por `smoke/`) eliminados. **36 passing.** Pendiente - menor: portar los casos de token inválido (401/400) de `rest.test.js` al smoke. -- ✅ **Rate-limit de publications** (`imports/startup/server/api.js`): reglas - `type: 'subscription'` vía `rateLimitSubscriptions` — 5/1000ms en `fireFrom*` y - `comments.forReference`; 10/1000ms en las subs geo (map pan/zoom). -- ✅ **Google Maps con `loading=async`** (`imports/startup/client/Gkeys.js`): - se envuelve `GoogleMapsLoader.createUrl` para añadir el parámetro (el paquete - `google-maps` npm no expone hook). *Verificar en staging que el mapa carga y el - aviso de consola desaparece.* -- ✅ **`meteor-accounts-t9n` actualizado** a `^2.6.0` (es/en OK). El paquete sigue - sin build gallego (`gl`), así que se mantiene el fallback gl→es documentado en - `i18n.js`. Traducción gallega de la app (`gl/common.json`): 0 claves faltantes. -- ✅ **Salto Bootstrap 4→5 (CSS/JS) hecho** — react-bootstrap v2 ahora corre sobre - su CSS nativo (BS5). **Compila y arranca; falta SOLO verificación visual en - staging** (BS4→5 cambia sutilezas de grid/gutters/tipografía en todas las páginas). - - Widgets jQuery/BS4 migrados a React (los blockers): navbar collapse - (`Navigation.js`/`NavItem.js`), carrusel del home (`Index.js` → ``, - conserva `.lazy` vía `onSlide`), dropdowns de idioma/tipo (`Profile.js`/ - `Fires.js` → ``), y toggle del feedback (`Feedback.js` → estado React). - Deps `bootstrap-carousel-swipe` y `alexwine:bootstrap-4` eliminadas; jQuery - global ya no se usa (solo `jquery`+`jquery-validation` vía npm en `validate.js`). - - CSS: `bootstrap@5` npm importado en `imports/startup/client/index.js` (antes - que `app.scss`, para que los overrides ganen). Renombres de utilidades: - `ml-auto`→`ms-auto`, `float-right`→`float-end`, `btn-block`→`w-100`, - `data-toggle`→`data-bs-toggle`, `sr-only`→`.visually-hidden` (shim). - - **QA visual pendiente en staging (todas las páginas):** formularios (react- - bootstrap ya emite markup BS5 → deberían mejorar), navbar, botones, modales, - cards, grid/gutters, y los widgets migrados (menú móvil, carrusel, dropdowns, - feedback). Alertas de `themeteorchef:bert` (comprobar que siguen bien sin el - jQuery de alexwine). - - Menor: `popper.js@1` en `package.json` es legacy sin uso (react-bootstrap trae - `@popperjs/core@2`) → se puede quitar. El bloque `.form-label` de `forms.scss` - ya es redundante (BS5 lo trae) salvo por el `display:block` explícito. -- **Limpieza menor:** migración 217 `// TODO remove falsepositives lowercase - collection`; `prerender.js` gating comentado; sección per-fire del sitemap - eliminada (era código muerto tras `firesMapEnabled=false`). - ---- - -## 3. Observabilidad (Sentry/GlitchTip) — casi cerrado - -- ✅ Server-side reporta a GlitchTip; ✅ cliente vía **túnel** `/sentry-tunnel` - (esquiva el 503 de Cloudflare). DSN en `settings-development.json` (gitignored). -- Pendiente: poner el DSN en el `METEOR_SETTINGS` de **producción** y confirmar - que el servidor de prod alcanza GlitchTip (IPv4). Nota: el túnel es un relay - acotado al proyecto propio (aceptable, documentado). -- Consola de dev: warnings de librería restantes solo se van con el punto 1. - ---- - -## 4. Cutover a producción (infra — fase 3, es lo grande) - -- **Migrar datos Mongo 3.2 → 7** (mongodump/restore) en el servidor real. -- **Desplegar el stack** (Docker) al servidor limpio vía el **ansible de - Comunes** — NO al viejo shiva. Ver `RUNBOOK.md` (stack compose local validado). -- **Coordinar orden con notificaciones:** la web NO debe desplegarse antes de que - el microservicio `tcef-notifications` emita en prod (si no, los usuarios dejan - de recibir avisos). Ver dependencia en `UPGRADE.md`. -- Secretos de prod (`METEOR_SETTINGS`, no el fichero gitignored), cron del - GeoLite2 City (MaxMind) en el nuevo host, proxy/TLS por el ansible compartido. - ---- - -## 5. Fuera del repo web (bloquean el "hecho" global, pistas aparte) - -- **tcef-notifications:** rollout por canal shadow→canary→full (FCM en canary - superado; email y telegram pendientes) y redeploy del servicio actualizado. -- **App móvil `fires_flutter`:** republicación en Play Store. - ---- - -Regla de trabajo en todo lo anterior: commits locales atómicos en `meteor3-wip`, -**smoke REST byte-idéntico** tras cada cambio, verificación visual en navegador, -y NUNCA `git push`. diff --git a/RUNBOOK.md b/RUNBOOK.md deleted file mode 100644 index afa81e5..0000000 --- a/RUNBOOK.md +++ /dev/null @@ -1,117 +0,0 @@ -# RUNBOOK — stack local dockerizado (fase 3, validación) - -Stack Docker Compose del sistema modernizado de "Todos contra el fuego": -web Meteor 3.1 (Node 22), MongoDB 7 (replica set de un nodo), Redis (AOF), -`tcef-notifications` y node-red. **Es el entorno de validación local previo a -producción** — el despliegue real irá al ansible de Comunes (fase 3 del plan); -este compose no toca el proxy ni la infra compartida. - -## Requisitos - -- Docker + Docker Compose v2. -- El repo hermano `../tcef-notifications` (el compose construye su imagen desde ahí). - -## Primer arranque - -```bash -# 1. Secretos (gitignorados; ver secrets/README.md) -cp settings-development.json secrets/meteor-settings.json -cp secrets/notifications.env.example secrets/notifications.env # MODE=dry-run - -# 2. Construir y levantar (el primer build de la web tarda: descarga meteor-tool -# y compila el bundle; ~10-20 min en frío) -docker compose build -docker compose up -d - -# 3. Comprobar salud -docker compose ps # todo "healthy" / mongo-init "exited (0)" -``` - -Servicios y puertos en el host: - -| Servicio | Contenedor | URL / puerto | -|---|---|---| -| web Meteor | `tcef-stack-web` | http://localhost:3200 | -| node-red | `tcef-stack-node-red` | http://localhost:1880 | -| MongoDB 7 | `tcef-stack-mongo` | solo red interna (usar `docker exec`) | -| Redis | `tcef-stack-redis` | solo red interna | -| notificaciones | `tcef-stack-notifications` | sin puerto (worker) | - -`mongo-init` es un one-shot: inicia el replica set `rs0` y fija -`db.migrations` a la versión 18 (los `up()` históricos ya son async, pero se -pinta v18 porque los datos reales de producción llegan pre-migrados). - -> Nota healthcheck: los checks usan `127.0.0.1`, no `localhost`. El `wget` de -> busybox (alpine) resuelve `localhost` a `::1` (IPv6) primero, pero los -> servidores Meteor/node-red escuchan solo en IPv4 → un `localhost` daría -> unhealthy aunque la app responda perfectamente por el puerto mapeado. - -## Smoke test REST (red de seguridad del contrato Flutter) - -Debe ser **byte-idéntico** a los snapshots committeados: - -```bash -MONGO_CONTAINER=tcef-stack-mongo MONGO_SHELL=mongosh MONGO_PORT=27017 \ - BASE_URL=http://localhost:3200 ./smoke/smoke.sh -``` - -## Operación diaria - -```bash -docker compose logs -f web # logs de un servicio (rotados: 3×10MB) -docker compose ps # estado + healthchecks -docker compose restart notifications # reiniciar un servicio -docker compose down # parar todo (los volúmenes persisten) -docker compose down -v # ⚠️ borra también mongo/redis/node-red -``` - -## Desplegar una nueva versión - -```bash -# web (tras cambios en el repo) -docker compose build web && docker compose up -d web - -# notificaciones (tras cambios en ../tcef-notifications) -docker compose build notifications && docker compose up -d notifications -``` - -Verificar tras cada despliegue de la web: `docker compose ps` healthy + smoke -REST byte-idéntico (arriba). - -## Backups y restauración (volúmenes locales) - -```bash -# Mongo: dump / restore de la db fuegos -docker exec tcef-stack-mongo mongodump --db fuegos --archive > fuegos.dump -docker exec -i tcef-stack-mongo mongorestore --archive --drop < fuegos.dump - -# node-red: tar del volumen de datos -docker run --rm -v tcef-stack_nodered-data:/data -v "$PWD:/backup" alpine \ - tar czf /backup/nodered-data.tgz -C /data . -``` - -## Rollback - -- **web**: `git checkout ` + `docker compose build web && docker compose up -d web`. - (En producción se conservará la imagen anterior etiquetada; aquí basta git.) -- **notificaciones**: igual, desde `../tcef-notifications`. `KILL_SWITCH=1` en - `secrets/notifications.env` + `docker compose up -d notifications` detiene - todo envío al instante sin parar el servicio. -- **datos**: restaurar el último dump (arriba). - -## Salvaguardas de notificaciones - -`secrets/notifications.env` arranca en `MODE=dry-run` (no envía nada). La -progresión dry-run → shadow → canary → full está documentada en -`../tcef-notifications/docs/rollout.md` y **nunca** se salta pasos. En este -stack local no hay credenciales reales salvo que las pongas tú. - -## Deuda conocida de este compose - -- `tcef-notifications` no expone endpoint HTTP de salud → healthcheck por - proceso (`pgrep`). Añadir `/healthz` al servicio cuando toque. -- node-red corre la imagen oficial vacía (los flows de los bots viven en - shiva; su migración de datos es parte del cutover real de fase 3). -- El driver mongodb 3.7 de `tcef-notifications` (elegido por el Mongo 3.2 de - producción) conecta con Mongo 7 en pruebas locales, pero al hacer el cutover - real a Mongo 7 debe subirse a driver 6.x (y habilitar change streams). diff --git a/UPGRADE.md b/UPGRADE.md deleted file mode 100644 index f71fdbb..0000000 --- a/UPGRADE.md +++ /dev/null @@ -1,375 +0,0 @@ -# Meteor upgrade — 1.6.1.1 → 2.5 (Mongo 3.2) → 3.1 (Mongo 7, on `meteor3-wip`) - -Incremental, verified upgrade of `todos-contra-el-fuego-web`. Branch: -`meteor3-upgrade` (base `tcef-master`). Local commits only — no push until -authorized. - -## ⚠️ Hard blocker for the final 3.x jump: MongoDB 3.2 - -Production runs against the shared replica set **rsmain on MongoDB 3.2.11**. -Meteor 3 ships the modern `mongodb` Node driver, which requires a server -**≥ 4.2/4.4**. Meteor **2.x still works against Mongo 3.2**. - -Therefore this branch lands on **Meteor 2.5** — empirically the highest release -whose bundled `mongodb` driver still connects to Mongo 3.2 (2.6 bumps the driver -to 4.x and fails; see "Escala ceiling" below) — with everything green. The -`meteor update --release 3.x` step (and even 2.6+) is **deliberately not taken** -until the Comunes infra team upgrades rsmain (shared infra — coordinated in a -separate phase, not unilaterally). Server code should still be migrated to -async/await before the final 3.x jump so it is mechanical (tracked as debt). - -## ⚠️ Deploy-ordering dependency: notifications cutover - -The old push/email notification code was removed from the web (see the -notifications commit) because it now lives in the **tcef-notifications** -microservice. **Production must not run this build until tcef-notifications is -emitting in prod** — otherwise users stop receiving notifications. The web -deploy and the notifications cutover must be coordinated. - -## Safety net: REST smoke test - -`smoke/` is a standalone Node harness that calls every REST endpoint the Flutter -app consumes, against a seeded local dev server, and diffs the responses against -committed snapshots (`smoke/snapshots/`). See `smoke/README.md`. - -**Run after every escala — it must stay byte-identical:** - -```bash -docker run -d --name tcef-mongo32 -p 27018:27017 mongo:3.2 # once -export PATH="$HOME/.meteor:$PATH" MONGO_URL="mongodb://localhost:27018/fuegos" -meteor --settings settings-development.json --port 3100 & # dev server -./smoke/smoke.sh # seed + compare -``` - -## Escalas - -### Baseline — Meteor 1.6.1.1 (starting point) - -- Boots against Mongo 3.2 with `settings-development.json`. -- Dev-enabling changes (do not affect production behavior): - - `IPGeocoder.js`: degrade gracefully when the MaxMind GeoLite2 DB is absent - (provisioned by cron in prod) instead of crashing at boot. - - `settings-development.json`: added dev-only `private.internalApiToken` - (`dev-smoke-token`) — the REST API only registers its routes when this is - set. -- **Notifications code removed** (migrated to tcef-notifications): deleted - `notificationsObserver.js`, `notificationsProcess.js`, the "Process pending - notif" SyncedCron job, and the `node-gcm` dependency. `subsUnion.js` kept (it - feeds `subs-public-union`, consumed by the app). -- REST smoke baseline captured here. ✅ green. - -### Escala 1 — 1.6.1.1 → 1.8.3 ✅ green - -```bash -meteor update --release 1.8.3 -meteor npm install --save @babel/runtime@^7.26.0 -``` - -Core packages bumped (highlights): `meteor` 1.8.6→1.9.3, `ecmascript` -0.10.7→0.13.2, `modules` 0.11.6→0.14.0, `mongo` 1.4.7→1.7.0, `npm-mongo` -2.2.34→**3.2.0** (mongodb driver 3.x — still fine against Mongo 3.2 server), -`webapp` 1.5.0→1.7.5, `standard-minifier-js` →2.5.2. `underscore` auto-added -(1.7 dropped it from meteor-base), `fetch` + `modern-browsers` added. - -**Breaking change — Babel beta → stable.** Boot failed with -`Cannot find module '@babel/runtime/helpers/objectSpread2'`: the pinned -`@babel/runtime@7.0.0-beta.44` predates helpers that 1.8's `ecmascript` emits. -Fixed by upgrading to stable **7.x** (`@babel/runtime@^7.29.7`). Note: `@latest` -resolves to `8.x`, which is too new for Meteor's 7-style helper layout — pin to -`^7`. - -REST smoke test: **byte-identical to baseline.** ✅ - -### Escala 2 — 1.8.3 → 1.11 (version resolution only) - -```bash -meteor update --release 1.11 -``` - -Resolution reached 1.11 (`ecmascript` 0.14.3, `mongo` 1.10.0, `npm-mongo` -**3.8.0** — still Mongo-3.2-compatible, `accounts-*` 1.x, `email` 2.0). But this -release surfaced the two big blockers below. The app was ultimately driven to -**2.3** (see next section); 1.11 was a transit point, not a landing. - -### Escala 3 — → Meteor 2.3 ✅ green (builds + runs against Mongo 3.2, smoke byte-identical) - -`.meteor/release = METEOR@2.3`. `npm-mongo` **3.9.0** (mongodb driver 3.x) — the -last driver line that still speaks to a Mongo 3.2 server. Core bumps: -`accounts-*` 2.0, `ecmascript` 0.15.2, `mongo` 1.12.0, HMR added. - -This escala was dominated by **dead Atmosphere packages** (the plan's #1 risk). -Root causes and fixes, in the order they were hit: - -1. **`arkham:comments-ui` — no Meteor 2.x build.** The vendored local package - used `Npm.depends`, and rebuilding a *local* package's npm deps crashes - meteor-tool ≥1.11 on this host (`node_contextify.cc … Assertion - args[1]->IsString()` → SIGABRT; reproducible with any `Npm.depends`, even - `is-number`). Switching to the Atmosphere isopack avoided the local rebuild - but the package is abandoned: its newest 2.x-solvable version (0.2.15) pins - `accounts-password@1.0.1`, conflicting with Meteor 2.0's accounts-password 2.0. - **Decision (user-approved): reimplement comments as a small React feature** - (`imports/api/Comments/*`, `imports/ui/components/Comments/CommentsBox.js`), - dropping the package entirely. Fire-page comments only (the sole usage), with - likes/dislikes, image/YouTube embed, owner edit/remove, and the - "email other commenters" side-effect preserved. Comment text is now rendered - as **safe plain text** (+ media embed) instead of markdown-to-HTML — a small - scope reduction and a security improvement (no raw-HTML injection of user - content). The old local fork is preserved in its nested git repo - (commit `4761da9`). - -2. **`coffeescript@1.0.17` build plugin — crashes the build.** The same - `node_contextify` assertion fired at *build start* on this host whenever the - coffeescript compiler plugin loaded. It was pulled transitively by the dead - **test stack** (`meteortesting:mocha`, `practicalmeteor:chai`, - `xolvio:cleaner`) and by the old auto-downgraded `nimble:restivus@0.6.6` - (which needs `iron:router`). Removing those removed coffeescript and - unblocked the build. A trivial `meteor create` app builds fine on the same - tool, confirming the crash is package-specific, not a broken tool. - -3. **`nimble:restivus` — the REST API package — pins `accounts-password@1.3.3`** - (incompatible with 2.x) and is CoffeeScript (needs the crashing plugin). - **Vendored** as `packages/nimble-restivus`: the `.coffee` sources were - precompiled to plain JS (`lib/restivus-all.js`, single translation unit so - the `Auth`/`Route`/`Restivus` classes share scope; `Restivus` left - un-`var`'d so Meteor's `api.export` picks it up), and `package.js` loosens - `accounts-password` to 2.x and drops the coffeescript dependency. This also - let restivus resolve to 0.8.12 (json-routes based), which **removed the whole - `iron:router` stack**. REST behavior unchanged (smoke byte-identical). - -4. **`maximum:server-transform` (unused) removal** broke - `Meteor.publishTransformed` in `FalsePositives/server/publications.js`; no - transform was actually configured, so replaced with plain `Meteor.publish`. - -5. **`fourseven:scss` 4.5.4 → 4.14.1** — `node-sass@4.5.3` won't build on node 12 - (Meteor ≥1.9); 4.14.1 uses a node-12-compatible node-sass with prebuilt - binaries. (Meteor 1.8 built it on node 8.) `4.15.0` needs ecmascript ≥ 0.15.1 - → too new for 1.11. - -6. **`less`, `markdown` removed** — no `.less`/`.md` source files; their build - plugins were dead weight. - -### ⚠️ Local build-host caveat - -Meteor-tool's bundled node (12.x/14.x) SIGABRTs (`node_contextify` assertion) on -this machine (kernel 6.12) when certain old build plugins load — this is an -environment interaction, not a code defect (the deploy host built these fine -historically). It was fully worked around by removing the dead build plugins -above. If a future escala hits it again, build in a container matching the -deploy target (Debian) rather than on this host. - -### Escalas 2.4 & 2.5 — ✅ green - -`meteor update --release 2.4`, then `--release 2.5`. Both trivial; `npm-mongo` -stays at **3.9.1** (mongodb driver 3.x). REST smoke byte-identical against -`mongo:3.2` on both. - -## Escala ceiling vs Mongo 3.2 — **landing on Meteor 2.5** (empirically verified) - -Meteor bumps the bundled `mongodb` Node driver to **4.3.1 at Meteor 2.6**, which -requires server wire version ≥ 6 (**MongoDB ≥ 3.6**). Production's rsmain is -**Mongo 3.2** (wire version 4). Verified directly by booting 2.6 against a real -`mongo:3.2`: - -``` -MongoCompatibilityError: Server at localhost:27018 reports maximum wire -version 4, but this version of the Node.js Driver requires at least 6 -(MongoDB 3.6) -=> Exited with code: 1 -``` - -So **Meteor 2.5 is the highest release that runs against the production Mongo -3.2** (npm-mongo 3.9.1). That is where this branch lands: everything green, REST -smoke byte-identical. Going past 2.5 (to 2.16 or 3.x) is gated on upgrading the -shared rsmain replica set to Mongo ≥ 4.4 first — the same DB blocker as 3.x. - -## ✅ Meteor 3.1 + MongoDB 7 — REST smoke GREEN (branch `meteor3-wip`) - -**The 3.x jump works end-to-end for the REST/Flutter contract.** On branch -`meteor3-wip`, the web runs on **Meteor 3.1** (Node 22, async/await, no Fibers) -against a **dockerized MongoDB 7** (npm-mongo 6.10 / mongodb driver 6), and the -REST smoke test is **byte-identical to the 1.6.1.1 baseline** (all 12 endpoints). -This is the "mongo superior" decision realized: give fuegos its own Mongo 7 -instead of the shared rsmain 3.2 — which is what unblocks Meteor 3's driver. - -`meteor3-upgrade` stays at the deployable **Meteor 2.5** (Mongo 3.2). The 3.x -work lives on `meteor3-wip` until (a) production Mongo is migrated to 7 and -(b) the web-UI async migration below is finished. - -### Reproduce the 3.1 dev stack -```bash -docker run -d --name tcef-mongo7 -p 27019:27019 mongo:7 --replSet rs0 --port 27019 --bind_ip_all -docker exec tcef-mongo7 mongosh --port 27019 --quiet --eval 'rs.initiate({_id:"rs0",members:[{_id:0,host:"localhost:27019"}]})' -# (migrations run from scratch on an empty DB — all up() bodies are async now, -# no need to pre-mark db.migrations at version 18) -export MONGO_URL="mongodb://localhost:27019/fuegos?replicaSet=rs0" -export NODE_OPTIONS="--dns-result-order=ipv4first --no-network-family-autoselection" # reach warehouse.meteor.com on Node 22 -meteor --settings settings-development.json --port 3100 -MONGO_CONTAINER=tcef-mongo7 MONGO_SHELL=mongosh MONGO_PORT=27019 ./smoke/smoke.sh -``` - -### Key build/runtime unblocks (Meteor 3.1) -- **underscore linker crash** (`Runtime is not available … underscore`): resolved - `underscore@1.6.2` had a runtime-less `web.browser` unibuild → pin - **`underscore@1.6.4`** (diagnosed by instrumenting the tool's `linker.js`). -- **collection2 v4** is fully lazy with no main module → import its eager entry - `meteor/aldeed:collection2/static.js` from `server|client/00-collection2-init.js` - so `attachSchema`/autoValues are patched before any collection loads. -- **Fibers gone**: removed `fibers.js`; server sync Mongo → `*Async` throughout - (Rest.js + helpers + methods + startup: oauth/subsUnion/migrations/email/facts). -- **vendored restivus** patched to `await` async endpoint handlers. -- **`$near` + `countAsync()`**: driver 6 runs `count` via aggregation where - `$near` is illegal → derive total from `fetchAsync().length`. -- **json-routes 3.0** matches routes in registration order → reordered the - `mobile/subscriptions/all/...` route before `.../:subsId` in Rest.js. -- **fixtures.js / sitemaps.js** disabled (sync-only `@cleverbeagle/seeder` / - pre-0.9 `gadicohen:sitemaps`) — not on the REST path. Debt. - -### Web-UI async migration (beyond the REST contract) -- ✅ **Fires publications → async** (`fireFromId`, `fireFromAlertId`, - `fireFromActiveId`, `fireFromHash`): `findOne`→`findOneAsync`, - `count()`→`countAsync()`, `firesUnion` awaited, handlers `async` so the - try/catch actually catches rejections. Verified over raw DDP (all subs - `ready`, docs flow; fire + falsePositives docs delivered for the seeded - fire). `falsePositivesMyloc` / `industriesMyloc` / `comments.forReference` - needed **no change** — they return bare `find()` cursors (still sync-safe on - Meteor 3). `subscriptions.*` methods were already `*Async`. -- Note: the module-level `new Counter(...)` (natestrauser:publish-performant-counts) - behind `falsePositivesTotal` boots fine; the publication itself is unused by - the current UI — watch it if re-enabled. -- ✅ **Comments methods → async** (`comments.insert/edit/remove/like/dislike`): - `findOne/insert/update/remove` → `*Async`, helpers (`requireOwner`, `toggle`) - async, `users.findOneAsync` for the username. `onCommentAdd` mail fan-out: - `users.find().forEach` → `forEachAsync`. Still fire-and-forget from the - insert. Comments UI staging verification pending (pre-existing note below). -- ✅ **fixtures.js re-enabled** — `@cleverbeagle/seeder` (sync Mongo, no Meteor - 3 support) **removed from package.json** and replaced with a small in-repo - async seeder: idempotent, dev-only (staging opts in with `TCEF_SEED=1`); - same accounts as before (`admin@admin.com` / 5 test users / 5 Documents - each). Verified: `fixtures: 6 dev users ensured` on boot. -- ✅ **sitemaps.js re-enabled** — `gadicohen:sitemaps` (pre-0.9 API, dead on - Meteor 3) **removed from .meteor/packages**; `/sitemap.xml` is now a small - `WebApp.connectHandlers` handler serving the same static page list (the - per-fire section of the old handler was behind `firesMapEnabled = false`, - i.e. dead code, and was dropped). Verified serving on dev. -- ✅ **Dead client packages purged — the web UI now RENDERS on Meteor 3.** - First-ever browser verification of this branch (the smoke only covers REST) - found a chain of ancient Blaze-era packages whose client code threw at - bundle load, killing the whole app (`meteorInstall is not defined`): - - **alanning:roles 1.2.10** (client crash, and its server - `Roles.userIsInRole` is sync) → package removed; the app barely used it: - `facts.js` now checks the plain `user.roles` field (admin set cached at - startup) and App.js takes `roles` from the user doc (prop was unused). - Upgrading to roles v4 was rejected: needs a prod data migration. - - **selaias:cookie-consent** (its `Cookies` global is gone) → removed; - replaced by in-repo React `imports/ui/components/CookieConsent` reusing - the same i18n keys; `CookieConsent.init` dropped from `i18n.js`. - - **natestrauser:publish-performant-counts** server `Counter._publishCursor` - uses sync `cursor.count()` → **vendored** as - `packages/publish-performant-counts` (`tcef:publish-performant-counts`) - with `countAsync` and an async-aware interval; same public API. - - `Meteor.autorun` (removed in Meteor 3) → dropped (App.js debug) / - `Tracker.autorun` (FiresMap). - - Map/server publications not exercised by the REST smoke converted to - async: `activefiresmyloc`, `activefiresunionmyloc`, `fireAlerts` - (countAsync/fetchAsync/awaited firesUnion), and `oauth.verifyConfiguration` - (findOneAsync). `migrations.js`: all 18 historical `up()` bodies ported to - async (`fetchAsync`+`for..of`, `*Async` writes, `createIndexAsync`, - `Accounts.createUserAsync`; percolate:migrations 2.0.1 awaits async `up()`). - Verified: empty Mongo 7 DB migrates 0→18 with no errors (indexes + - industry registries created); pre-marking `version=18` is no longer needed. - Verified in a real browser: `/` and `/fires` render (map, search, layers, - cookie banner), zero uncaught exceptions; REST smoke byte-identical. -- ✅ **React 16 → 18.3** — `react`/`react-dom` bumped to `^18.3.1` - (`--legacy-peer-deps`: the ancient react-* libs below declare 15/16 peers), - client entry migrated to `createRoot` (`imports/startup/client/index.js`). - The legacy UI libs (react-bootstrap 0.31, reactstrap 5-alpha, react-leaflet - 1.8, react-router-dom 4, react-i18next 7) stay pinned and WORK on 18, but - spam the console with legacy-context/defaultProps deprecation warnings and - will die on React 19. **Debt**: own front-end refresh project. - ⚠️ npm gotcha: running `meteor npm install/uninstall` while the dev server - watches node_modules can crash the meteor-tool watcher mid-prune (ENOENT on - a watched file). Stop the server (or restart it after) when touching deps. - -### Still TODO before the web (not just REST) is fully deployable on 3.x -- ✅ **React 16 → 18** render root + ancient react-* libs modernized: react-helmet→ - react-helmet-async, i18next 10→23 + react-i18next 7→14, react-bootstrap 0.31→2, - react-router-dom 4→6. Dead deps (reactstrap, react-leaflet-sidebarv2, - react-router-hash-link, react-addons-pure-render-mixin) dropped. All - browser-verified, REST smoke byte-identical. **Remaining front debt:** - react-leaflet 1.8→4 (deferred — core map, 4 v1-only plugins) and the - Bootstrap 4→5 CSS/JS jump (deferred — jQuery carousel/navbar). Both are what's - left before a React 19 jump. -- ✅ Re-enabled dev fixtures / sitemaps / email default template with async ports. -- ✅ SCSS `@import`→`@use`, `lighten`/`darken`→`color.adjust` (dart-sass deprecations). -- **Production cutover** still needs: Mongo data migrated 3.2→7 (mongodump/restore), - the notifications service emitting in prod, and the Docker deployment (fase 3). - -## Dependency debt (tracked, to resolve in the noted escala) - -| Dep | From | Target | Status | -|---|---|---|---| -| React / react-dom | 16.0 | 18 | ✅ done — 18.3.1 + createRoot. The peripheral react-* libs that only worked on 18 via legacy context are now all modernized (rows below) **except react-leaflet** (deferred). Our own React-19 blockers are also cleared: `defaultProps` on function components → default params; `UNSAFE_componentWillReceiveProps` → `getDerivedStateFromProps`/`componentDidUpdate`; the Blaze/findDOMNode `Reconnect` → native React; react-share 2→5 and react-progress-bar.js → progressbar.js (both dropped their findDOMNode/defaultProps). react-leaflet 1.8→4.2 done too (row below). **Dev console is now 0 React warnings** on the main routes. The only React-19 leftover is the Blaze `serverFacts` on the /status admin page (findDOMNode via gadicc:blaze-react-component). | -| react-share | 2.0 | 5.3 | ✅ done — v2 shipped function-component `defaultProps` (React-19 blocker). v5 is clean. Dropped the dead GooglePlus button (removed upstream in v4+); the other 6 share buttons are API-compatible. | -| react-progress-bar.js | 0.2.3 | — (progressbar.js 1.1) | ✅ removed — the wrapper used the deprecated `findDOMNode`. `LoadingBar` now drives `progressbar.js` (its own underlying dep, promoted to direct) through a ref. | -| gadicc:blaze-react-component (in Reconnect) | — | native React | ✅ Reconnect's Blaze `meteorStatus` bridge used `findDOMNode` and was always mounted → rewritten with `useTracker(Meteor.status)` + countdown effect, reusing 255kb:meteor-status's CSS. (Status.js's Blaze `serverFacts` kept — /status admin page only.) | -| Server async/await | Fibers | `*Async` APIs | ✅ done — REST, publications, methods, startup and migrations all on `*Async`. | -| bcrypt | 1.0.3 (broken binding) | 5.x | ✅ done — 1.0.3 didn't load on Node 22 (accounts-password silently fell back to pure JS) and wouldn't compile in the Docker build; 5.1.1 ships Node-22 prebuilds. | -| raven / flowkey:raven | 2.4 | @sentry/node + @sentry/browser 8.x | ✅ done — `flowkey:raven` (Sentry legacy SDK, dead on 3.x, spammed the boot log with 502s against the dead sentry.comunes.org) replaced by the modern SDKs behind the same `ravenLogger.log()` facade (6 call sites unchanged). Backend: **GlitchTip** self-hosted on `aaron` (Sentry-compatible DSN/API; Sentry proper needs 16 GB RAM, didn't fit), deployed via the Comunes ansible (`glitchtip.yml`, org "comunes" / project "tcef-web"), fronted at `https://sentry.comunes.org` (nginx on `assange` → `aaron:8000`). DSN set in `settings-development.json`; end-to-end verified (a test exception sent from the dev server showed up in GlitchTip within seconds). | -| nodemailer | 4 | 6.10 | ✅ done — drop-in for our usage: `email.js` only calls `nodemailer.createTransport(MAIL_URL)` and reads `transport.options.auth.user` (in the production-only `from()` branch); both verified unchanged in v6. Sending goes through `ostrio:mailer` (MailTime 2.5), which is v6-compatible. | -| Babel | 7 beta | 7 stable | ✅ done (escala 1) | -| react-meteor-data | 0.2.16 | 3.0.6 | ✅ done — 0.2.16 (React-15/16-era) looped `withTracker` on fire-detail pages under React 18 (never-ready → blank). `meteor add react-meteor-data@3.0.6`; no call-site changes (HOC API kept). Also dropped `tmeasday:check-npm-versions` (the constraint pinning 0.2.16). See section above. | -| i18next | 10.5 | 23.16 | ✅ done — with react-i18next 14. `whitelist`→`supportedLngs`, added `compatibilityJSON: 'v3'` (our locale JSON uses natural-language keys + v3 `_plural` layout, not the v4 `_one`/`_other` suffixes), custom separators `ß`/`ð`/`đ` kept. `sendMissing`→`saveMissing`, missingKeyHandler signature is `(lngs, ns, key, fallbackValue)` now (array first). See react-i18next row. | -| react-i18next | 7.4 | 14.1 | ✅ done — 48 `translate([], {wait:true})`/`translate()` HOCs → `withTranslation()`; `react.wait:true`→`react.useSuspense:false`. `` unchanged (locale JSON already stores the indexed-tag format `<1><0>{{x}}`). ReSendEmail: removed `` (deleted in v10) → ``, dropped the removed bare `t` export. This is what silenced the per-component `Translate`/`I18n` legacy-context console spam. Backends: xhr→`i18next-http-backend` (client), sync-fs→`i18next-fs-backend` (server); `i18next-localstorage-cache` dropped (was `enabled:false`); languagedetector 2→8. Browser-verified: es/en switch, `` interpolation (`{{countTotal}}`+``) renders on /fires. | -| react-leaflet + leaflet | 1.8 / 1.3.1 | 4.2.1 / 1.9.4 | ✅ done — the last and hardest lib; a ground-up hooks rewrite of the app's core map. ``→``; `.leafletElement` refs (6 files/13 sites) → the map/layer instances directly (via a `` child using `useMap`, and plain refs on Marker/Circle/GeoJSON); the controlled-viewport pattern (`onViewportChanged`+`state.center/zoom`) → `` (`useMapEvents` moveend/zoomend) + imperative `setView`; `onClick`→`eventHandlers={{click}}`, path styling → `pathOptions`/`style`; `subsUnion` takes the `L.Map` directly. **The 4 v1-only plugins were reimplemented** with react-leaflet v4's factories / vanilla Leaflet plugins: `react-leaflet-control`→ in-repo `MapControl` (L.Control + createPortal), `react-leaflet-google`→ `GoogleMutantLayer` (`createLayerComponent` + `leaflet.gridlayer.googlemutant`; Google Maps API already loaded by Gkeys), `react-leaflet-fullscreen`→ `createControlComponent` + `leaflet.fullscreen`, `leaflet-sleep`/`leaflet-graphicscale` kept as vanilla (work on leaflet 1.9). Browser-verified: /fires (tiles, OSM+Google layer switch, custom control, fullscreen, graphic scale, pan/zoom→re-fetch pipeline, no NaN), fire detail (GeoJSON rect + fitBounds), home (all 3 maps coexist; SelectionMap draggable marker→updatePosition + distance circle). **Console now 0 warnings** across home/`/fires` (forced full re-render). REST smoke byte-identical. | -| arkham:comments-ui | 1.4.x | — | ✅ replaced with in-repo React feature | -| nimble:restivus | Atmosphere | vendored | ✅ local precompiled package, accounts-password 2.x | -| maximum:server-transform, meteorhacks:zones, less, markdown, test stack | — | — | ✅ removed (dead/unused) | -| fourseven:scss | 4.5.4 | 4.14.1 | ✅ node-12 compatible | -| node-gcm | 1.0.2 | — | ✅ removed (dead API, moved to microservice) | -| react-router-dom | 4.2.2 | 6.30 | ✅ done — with `history` 4→5 and `react-router-bootstrap` 0.24→0.26. Kills the `Router`/`Switch`/`Route`/`Link`/`LinkContainer` legacy-context warnings. Strategy: keep the shared `history` **singleton** (used outside React by NotificationsObserver and Utils/location) via `unstable_HistoryRouter`, and DON'T rewrite the ~20 class pages — a new `withRouterCompat` HOC bridges v6 hooks back to v4-shaped `history`/`match`/`location` props. ``→``, `component=`→`element=`, `Authenticated`/`Public` become guard components rendering `children` or ``, `LocationListener` is now a `useLocation`+`useEffect` function. The regex route `/fire/:type(active\|archive\|alert)/:id` → `/fire/:type/:id` (type validated in the component; the only behavior delta is an invalid two-segment type now hits Fires instead of 404 — negligible). `history.listen` callback is `({location})` in v5. Browser-verified: SPA nav, `/subscriptions`→`/login` auth redirect, deep-link `/fire/archive/:id` (params flow), browser back/forward; REST smoke byte-identical. Gotcha hit & fixed: the rewritten guard files need `import React` for their `` JSX. | -| react-helmet | 5.2.0 | react-helmet-async 2.0.5 | ✅ done — react-helmet is unmaintained and misbehaves under React 18 StrictMode. Drop-in: same `` children API across the 15 pages; `` wraps `` in `imports/startup/client/index.js`. Verified in browser: per-page titles, meta description and hreflang alternates injected (`data-rh`). Note: head updates are rAF-deferred (like react-helmet), so hidden/background tabs don't flush — irrelevant for real users. | -| react-bootstrap | 0.31.5 | 2.10 | ✅ done — kills the biggest batch of React-19-blocking warnings (Grid/FormGroup/ControlLabel/Navbar.Header/Navbar.Brand/Checkbox/SafeAnchor all used legacy context/defaultProps). 29 files: `Grid`→`Container`, `FormGroup/ControlLabel/FormControl/HelpBlock`→`Form.Group/Label/Control/Text`, `Checkbox`→`Form.Check` (label moves to a prop), `bsStyle`→`variant` (×23, `"default"`→`"secondary"`), `bsSize`→`size` (×2), `pull-right`→`float-end` (×7). Custom `Col.js` (used v0.31 `bootstrapUtils`/`StyleConfig` internals) now re-exports v2's `Col`; custom `NavItem.js` rewritten self-contained (dropped `SafeAnchor`+`createChainedFunction`); `Navigation.js` drops the react-bootstrap `Navbar` (was hand-rolled raw markup anyway). **CSS kept at Bootstrap 4** (see next row) — the only BS5-only class v2 emits that BS4 lacks is `.form-label`, shimmed in `forms.scss`. Browser-verified: home carousel+navbar, signup form + terms `Form.Check` (toggles submit), login form; REST smoke byte-identical. | -| Bootstrap CSS/JS | 4.1 (alexwine:bootstrap-4) | 5.x | ⏸️ **deferred as debt.** The app's CSS+JS still comes from the Atmosphere `alexwine:bootstrap-4` (Bootstrap 4 + jQuery). The BS4→5 jump is a separate invasive sub-project: the home **carousel** uses the jQuery `bootstrap-carousel-swipe` plugin (BS4-only), the **navbar collapse** and ~17 `data-toggle`/`data-slide` attributes across 6 files are BS4 jQuery behaviors, and BS5 is vanilla-JS with `data-bs-*`. react-bootstrap v2 runs fine on BS4 CSS for every component we use (Button/Alert/Row/Col/Table/ButtonGroup/Modal render identical classes; only `.form-label` needed a shim). Do this jump on its own: npm `bootstrap@5` SCSS+JS, rewrite carousel (BS5 has native swipe → drop the plugin), `data-toggle`→`data-bs-toggle`, `ml-auto`→`ms-auto`, etc. | -| reactstrap | 5.0.0-alpha.3 | — | ✅ removed — zero imports in the codebase (react-bootstrap covers all UI). | -| react-leaflet-sidebarv2 | 0.5.1 | — | ✅ removed — never imported. | -| react-router-hash-link | 1.1.1 | — | ✅ removed — only a commented-out import in Index.js. | -| react-addons-pure-render-mixin | 15.6.2 | — | ✅ removed — never imported (React-15 era leftover). | -| chimp | 0.51.1 | — | ✅ removed — dead e2e test runner (Selenium 2 / cucumber), sole remaining puller of native `fibers@1.x`, which cannot compile on Node 22 and broke the clean Docker build. Not imported anywhere. | - -### Comments React feature — verification note - -The server builds and the REST smoke test is green, and the client bundle -compiles, but the comments UI itself is **not exercised by the smoke test** -(it's not part of the Flutter REST contract). It needs manual staging -verification: on a fire archive page — logged-in post/edit/remove, like/dislike, -and image/YouTube embed. - -### ✅ Fixed: fire-detail pages now render (react-meteor-data 0.2.16 → 3.0.6) - -Browser-verifying the Comments feature surfaced a **pre-existing client bug**: -the fire-detail pages (`/fire/{active,archive,alert}/:id`, component -`imports/ui/pages/Fires/Fires.js`) stayed blank — the page's `withTracker` -subscription (`fireFromActiveId` / `fireFromId`) never reached `ready()`, so -`loading` stayed true and nothing rendered, pegging a CPU core in a -re-subscribe loop. - -Diagnosis (isolated, not guessed): -- **The publications were fine.** Subscribing to `fireFromActiveId` / - `fireFromId` *standalone* from the browser console readied immediately and - delivered the doc — not a server/publication bug. -- **The bug was `react-meteor-data@0.2.16`** — a React-15/16-era version - (`componentWillMount` + legacy context, the source of the deprecation - warnings flooding the console) that misbehaves under React 18 for a component - gating its whole render on `subscription.ready()`. - -Fix: **`meteor add react-meteor-data@3.0.6`** (the React-18-compatible line for -Meteor 3.1; 4.x targets Meteor 3.2+). No call-site changes were needed — 3.x -keeps the `withTracker` HOC API, and all 19 call sites work unchanged. The old -constraint that pinned 0.2.16 was **`tmeasday:check-npm-versions`**, pulled in -by 0.2.16 itself; the upgrade dropped it, which also silenced the -"npm peer requirements not installed (react@15-16)" boot/console warning. - -Verified in a real browser at `/fire/archive/c0000000000000000000000c`: the -page renders fully (title, Leaflet + NASA/satellite map, the "not a wildfire" -FalsePositives widget, and the **Comments** box — all `withTracker`-driven), -zero console errors. REST smoke byte-identical (12/12). diff --git a/bench/ddp-load.js b/bench/ddp-load.js deleted file mode 100644 index f8ad565..0000000 --- a/bench/ddp-load.js +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env node -/* - * Carga de clientes DDP + REST (fase 12). - * - * Abre N conexiones DDP como las del navegador (WebSocket crudo contra - * /websocket, sin librería), cada una suscrita a lo que suscribe el mapa - * —`activefiresmyloc` y `activefiresunionmyloc`— y, con todas conectadas, mide - * la latencia de ida y vuelta de un método. Es la pregunta de la fase: con - * mucha gente mirando el mapa, ¿sigue respondiendo la web? - * - * En paralelo golpea los endpoints REST que usa la app Flutter. - * - * node bench/ddp-load.js --clients 50 - * node bench/ddp-load.js --clients 200 --seconds 30 - * BASE_URL=http://localhost:3100 node bench/ddp-load.js --clients 500 - * - * Contra un servidor de desarrollo o el compose local. NUNCA contra staging o - * producción: son cientos de conexiones y de llamadas. - */ - -const BASE = (process.env.BASE_URL || 'http://localhost:3100').replace(/\/$/, ''); -if (/testfuegos|fuegos\.comunes|fires\.comunes/.test(BASE)) { - console.error(`Me niego a lanzar carga contra ${BASE}.`); - process.exit(1); -} - -const arg = (name, def) => { - const i = process.argv.indexOf(`--${name}`); - return i === -1 ? def : Number(process.argv[i + 1]); -}; - -const CLIENTS = arg('clients', 50); -const SECONDS = arg('seconds', 20); -const WS_URL = `${BASE.replace(/^http/, 'ws')}/websocket`; - -// El mismo recuadro que pide el mapa al abrirse sobre la península. -const BOX = [3.3, 43.7, -9.2, 36.0]; - -const percentile = (values, p) => { - if (values.length === 0) return NaN; - const sorted = [...values].sort((a, b) => a - b); - return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]; -}; - -const openClient = (id) => new Promise((resolve, reject) => { - const ws = new WebSocket(WS_URL); - const state = { - id, ws, ready: 0, subs: 0, errors: 0, latencies: [], pending: new Map() - }; - const timer = setTimeout(() => reject(new Error(`cliente ${id}: sin conectar en 30 s`)), 30000); - - ws.addEventListener('open', () => { - ws.send(JSON.stringify({ msg: 'connect', version: '1', support: ['1'] })); - }); - - ws.addEventListener('message', (event) => { - // Meteor manda varios mensajes DDP en frames separados, uno por frame. - let m; - try { m = JSON.parse(event.data); } catch (e) { return; } - - if (m.msg === 'connected') { - state.connectedAt = Date.now(); - ws.send(JSON.stringify({ - msg: 'sub', id: `s${id}a`, name: 'activefiresmyloc', params: [...BOX, false] - })); - ws.send(JSON.stringify({ - msg: 'sub', id: `s${id}b`, name: 'activefiresunionmyloc', params: [...BOX, false] - })); - clearTimeout(timer); - resolve(state); - } else if (m.msg === 'ready') { - state.subs += (m.subs || []).length; - if (!state.ready) state.ready = Date.now() - state.connectedAt; - } else if (m.msg === 'nosub') { - state.errors += 1; - } else if (m.msg === 'result') { - const started = state.pending.get(m.id); - if (started) { - state.pending.delete(m.id); - state.latencies.push(Date.now() - started); - if (m.error) state.errors += 1; - } - } else if (m.msg === 'ping') { - ws.send(JSON.stringify({ msg: 'pong', id: m.id })); - } - }); - - ws.addEventListener('error', () => { - state.errors += 1; - clearTimeout(timer); - reject(new Error(`cliente ${id}: error de websocket`)); - }); -}); - -let callSeq = 0; -const callMethod = (state) => { - if (state.ws.readyState !== 1) return; - const id = `m${state.id}_${callSeq += 1}`; - state.pending.set(id, Date.now()); - // Método barato y real: el que usa el cliente para pedir la clave de mapas. - state.ws.send(JSON.stringify({ - msg: 'method', method: 'getMapKey', params: [], id - })); -}; - -const restRound = async () => { - const started = Date.now(); - const res = await fetch(`${BASE}/api/v1/status/uptime`).catch(() => null); - return { ok: !!res && res.ok, ms: Date.now() - started }; -}; - -const main = async () => { - console.log(`Abriendo ${CLIENTS} clientes DDP contra ${WS_URL} …`); - const started = Date.now(); - const results = await Promise.allSettled( - Array.from({ length: CLIENTS }, (_, i) => openClient(i)) - ); - const clients = results.filter(r => r.status === 'fulfilled').map(r => r.value); - const failed = results.length - clients.length; - console.log(`Conectados ${clients.length}/${CLIENTS} en ${((Date.now() - started) / 1000).toFixed(1)} s${failed ? ` (${failed} fallos)` : ''}`); - - // Un momento para que se completen las suscripciones antes de medir. - await new Promise(r => setTimeout(r, 3000)); - const readies = clients.map(c => c.ready).filter(Boolean); - console.log(`Suscripciones listas: p50 ${percentile(readies, 50)} ms · p95 ${percentile(readies, 95)} ms · sin ready ${clients.length - readies.length}`); - - console.log(`Midiendo ${SECONDS} s de llamadas…`); - const restLatencies = []; - const methodTimer = setInterval(() => clients.forEach(callMethod), 1000); - const restTimer = setInterval(async () => { - const r = await restRound(); - if (r.ok) restLatencies.push(r.ms); - }, 200); - - await new Promise(r => setTimeout(r, SECONDS * 1000)); - clearInterval(methodTimer); - clearInterval(restTimer); - await new Promise(r => setTimeout(r, 2000)); - - const latencies = clients.flatMap(c => c.latencies); - const errors = clients.reduce((a, c) => a + c.errors, 0); - const pending = clients.reduce((a, c) => a + c.pending.size, 0); - - console.log(''); - console.log(`Método DDP (getMapKey): ${latencies.length} respuestas · p50 ${percentile(latencies, 50)} ms · p95 ${percentile(latencies, 95)} ms · p99 ${percentile(latencies, 99)} ms · máx ${Math.max(...latencies, 0)} ms`); - console.log(`REST status/uptime : ${restLatencies.length} respuestas · p50 ${percentile(restLatencies, 50)} ms · p95 ${percentile(restLatencies, 95)} ms`); - console.log(`Errores: ${errors} · llamadas sin responder al cerrar: ${pending}`); - - clients.forEach(c => c.ws.close()); - process.exit(0); -}; - -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/bench/treeUnionWorker.js b/bench/treeUnionWorker.js deleted file mode 100644 index 138688f..0000000 --- a/bench/treeUnionWorker.js +++ /dev/null @@ -1,49 +0,0 @@ -// Variante SOLO PARA EL BANCO DE CARGA del worker de uniones. -// -// El worker de producción une en cadena: `u = union(u, c[i])` para cada círculo. -// Eso hace que cada llamada trabaje sobre un polígono que no para de crecer, así -// que el coste crece mucho más deprisa que el número de suscripciones. Esta -// variante une en árbol (pares, luego pares de pares…): el mismo resultado -// geométrico, pero la mayoría de las uniones son entre polígonos pequeños. -// -// Está aquí, y no en private/, para que no acabe en el bundle: es material de -// medición. Si los números lo justifican, se porta al worker de verdad. - -const { parentPort, workerData } = require('worker_threads'); - -// eslint-disable-next-line import/no-dynamic-require -const tcircle = require(workerData.turfPaths.circle).default; -// eslint-disable-next-line import/no-dynamic-require -const tunion = require(workerData.turfPaths.union).default; -// eslint-disable-next-line import/no-dynamic-require -const ttrunc = require(workerData.turfPaths.truncate).default; - -const truncOptions = { precision: 6, coordinates: 2 }; - -const run = () => { - const { subs, baseUnion, steps } = workerData; - let level = subs.map(s => ttrunc(tcircle( - [s.location.lon, s.location.lat], - s.distance, - { units: 'kilometers', steps: steps || 144 } - ), truncOptions)); - - if (level.length === 0) return baseUnion || null; - - while (level.length > 1) { - const next = []; - for (let i = 0; i < level.length; i += 2) { - if (i + 1 < level.length) next.push(ttrunc(tunion(level[i], level[i + 1]), truncOptions)); - else next.push(level[i]); - } - level = next; - } - - return baseUnion ? ttrunc(tunion(baseUnion, level[0]), truncOptions) : level[0]; -}; - -try { - parentPort.postMessage({ union: run() }); -} catch (e) { - parentPort.postMessage({ error: e.message }); -} diff --git a/bench/union-bench.js b/bench/union-bench.js deleted file mode 100644 index 63242b9..0000000 --- a/bench/union-bench.js +++ /dev/null @@ -1,217 +0,0 @@ -#!/usr/bin/env node -/* - * Banco de carga de la unión de suscripciones (fase 12). - * - * Ejecuta el MISMO worker que corre en producción - * (private/workers/unionWorker.js) contra suscripciones sintéticas realistas, y - * mide lo que la fase pide: cuánto tarda un recreate completo, cuánto un - * incrementalAdd, cuánto se atasca el event loop del proceso padre mientras - * tanto, cuánto ocupa el documento resultante frente al límite de 16 MiB de - * Mongo, y cuánta RAM se come el proceso. - * - * No necesita Meteor ni Mongo: la orquestación (cola, fast-path, guardado) ya - * está cubierta por test/server/subsUnion.test.js. Lo que aquí interesa es la - * parte cara, que es la geometría. - * - * node bench/union-bench.js # 1000 5000 10000 - * node bench/union-bench.js --sizes 500,1000 # otros tamaños - * node bench/union-bench.js --steps 64 # círculos menos detallados - * node bench/union-bench.js --strategy tree # unión en árbol en vez de en cadena - * node bench/union-bench.js --markdown # tabla lista para pegar en la fase - */ - -const path = require('path'); -const { Worker } = require('worker_threads'); -const { monitorEventLoopDelay } = require('perf_hooks'); - -const ROOT = path.resolve(__dirname, '..'); -const WORKER_PATH = path.join(ROOT, 'private/workers/unionWorker.js'); -const TREE_WORKER_PATH = path.join(__dirname, 'treeUnionWorker.js'); -const NPM_MODULES = path.join(ROOT, 'node_modules'); -const turfPaths = { - circle: path.join(NPM_MODULES, '@turf/circle'), - union: path.join(NPM_MODULES, '@turf/union'), - truncate: path.join(NPM_MODULES, '@turf/truncate') -}; - -const arg = (name, def) => { - const i = process.argv.indexOf(`--${name}`); - return i === -1 ? def : process.argv[i + 1]; -}; -const flag = name => process.argv.includes(`--${name}`); - -const SIZES = String(arg('sizes', '1000,5000,10000')).split(',').map(Number); -const STEPS = Number(arg('steps', 144)); -const STRATEGY = arg('strategy', 'sequential'); -const TIMEOUT_MS = Number(arg('timeout', 20 * 60 * 1000)); - -// Suscripciones sintéticas con la forma de las reales: la mayoría en la -// península, un puñado en Canarias y Baleares, radios de 5 a 100 km. Sembradas -// con un PRNG determinista para que dos ejecuciones sean comparables. -let seed = 20260801; -const rnd = () => { - seed = (seed * 1664525 + 1013904223) % 4294967296; - return seed / 4294967296; -}; - -const REGIONS_ES = [ - { name: 'peninsula', weight: 0.88, lat: [36.0, 43.7], lon: [-9.2, 3.3] }, - { name: 'canarias', weight: 0.07, lat: [27.6, 29.4], lon: [-18.2, -13.3] }, - { name: 'baleares', weight: 0.05, lat: [38.6, 40.1], lon: [1.2, 4.3] } -]; - -// El peor caso para el TAMAÑO del documento: suscripciones repartidas por todo -// el mundo, que casi no se solapan, así que la unión conserva un polígono por -// suscripción en vez de fundirlas. Es el escenario contra el que existe -// unionSizeGuard.js, y el que hay que validar bajo carga. -const REGIONS_WORLD = [ - { name: 'mundo', weight: 1, lat: [-55, 65], lon: [-170, 170] } -]; - -const REGIONS = arg('spread', 'es') === 'world' ? REGIONS_WORLD : REGIONS_ES; - -const pickRegion = () => { - const r = rnd(); - let acc = 0; - for (const region of REGIONS) { - acc += region.weight; - if (r <= acc) return region; - } - return REGIONS[0]; -}; - -const makeSubs = (n) => { - const subs = []; - for (let i = 0; i < n; i += 1) { - const region = pickRegion(); - const lat = region.lat[0] + (rnd() * (region.lat[1] - region.lat[0])); - const lon = region.lon[0] + (rnd() * (region.lon[1] - region.lon[0])); - // Radios: la mayoría pequeños, con cola larga hasta 100 km. - const distance = Math.round(5 + (rnd() ** 2) * 95); - subs.push({ location: { lat, lon }, distance }); - } - return subs; -}; - -const runWorker = (subs, baseUnion, { steps, strategy }) => new Promise((resolve, reject) => { - const script = strategy === 'tree' ? TREE_WORKER_PATH : WORKER_PATH; - const worker = new Worker(script, { workerData: { subs, baseUnion, turfPaths, steps } }); - const timer = setTimeout(() => { - worker.terminate(); - reject(new Error(`worker sin responder tras ${TIMEOUT_MS} ms`)); - }, TIMEOUT_MS); - worker.once('message', (msg) => { - clearTimeout(timer); - worker.terminate(); - if (msg.error) reject(new Error(msg.error)); - else resolve(msg.union); - }); - worker.once('error', (err) => { - clearTimeout(timer); - worker.terminate(); - reject(err); - }); -}); - -// Mide, mientras corre `fn`: duración, retraso máximo del event loop del PADRE -// (que es donde vive DDP: es la métrica que importa para "¿se congela la web?") -// y pico de RSS del proceso. -const measure = async (fn) => { - const loop = monitorEventLoopDelay({ resolution: 10 }); - loop.enable(); - let peakRss = process.memoryUsage().rss; - const sampler = setInterval(() => { - const { rss } = process.memoryUsage(); - if (rss > peakRss) peakRss = rss; - }, 100); - // Un "latido" cada 20 ms: si el event loop se bloquea, se notan los saltos. - let beats = 0; - const beat = setInterval(() => { beats += 1; }, 20); - - const started = process.hrtime.bigint(); - let result; - let error = null; - try { - result = await fn(); - } catch (e) { - error = e; - } - const ms = Number(process.hrtime.bigint() - started) / 1e6; - - clearInterval(sampler); - clearInterval(beat); - loop.disable(); - - return { - ms, - result, - error, - maxLagMs: loop.max / 1e6, - p99LagMs: loop.percentile(99) / 1e6, - peakRssMb: peakRss / 1024 / 1024, - beats, - expectedBeats: Math.floor(ms / 20) - }; -}; - -const sizeOf = (union) => Buffer.byteLength(JSON.stringify(union), 'utf8'); -const mb = n => (n / 1024 / 1024).toFixed(2); -const pointCount = (union) => { - if (!union) return 0; - const g = union.geometry || union; - const polys = g.type === 'MultiPolygon' ? g.coordinates : [g.coordinates]; - return polys.reduce((acc, p) => acc + p.reduce((a, r) => a + r.length, 0), 0); -}; - -const main = async () => { - const rows = []; - console.log(`Estrategia: ${STRATEGY} · steps por círculo: ${STEPS} · timeout: ${TIMEOUT_MS} ms\n`); - - for (const n of SIZES) { - const subs = makeSubs(n); - process.stdout.write(`── ${n} suscripciones ─────────────────────────\n`); - - const full = await measure(() => runWorker(subs, null, { steps: STEPS, strategy: STRATEGY })); - if (full.error) { - console.log(` recreate: FALLÓ tras ${(full.ms / 1000).toFixed(1)} s — ${full.error.message}\n`); - rows.push({ n, full, inc: null, bytes: null, points: null }); - continue; - } - - const bytes = sizeOf(full.result); - const points = pointCount(full.result); - console.log(` recreate : ${(full.ms / 1000).toFixed(1)} s`); - console.log(` doc unión : ${mb(bytes)} MB (${points} vértices) — ${((bytes / (16 * 1024 * 1024)) * 100).toFixed(1)}% del límite de Mongo`); - console.log(` event loop : max ${full.maxLagMs.toFixed(1)} ms · p99 ${full.p99LagMs.toFixed(1)} ms · latidos ${full.beats}/${full.expectedBeats}`); - console.log(` RSS pico : ${full.peakRssMb.toFixed(0)} MB`); - - // El caso común en producción: una suscripción nueva sobre la unión ya - // guardada. Es el camino que decide si "suscribirse" es instantáneo o no. - const one = makeSubs(1); - const inc = await measure(() => runWorker(one, full.result, { steps: STEPS, strategy: STRATEGY })); - if (inc.error) { - console.log(` incrementalAdd: FALLÓ — ${inc.error.message}\n`); - } else { - console.log(` incrementalAdd: ${inc.ms.toFixed(0)} ms · event loop max ${inc.maxLagMs.toFixed(1)} ms\n`); - } - - rows.push({ n, full, inc, bytes, points }); - } - - if (flag('markdown')) { - console.log('\n| subs | recreate | incrementalAdd | doc unión | % de 16 MiB | lag máx. del event loop | RSS pico |'); - console.log('|---:|---:|---:|---:|---:|---:|---:|'); - for (const r of rows) { - if (!r.bytes) { - console.log(`| ${r.n} | ✗ (${(r.full.ms / 1000).toFixed(0)} s) | — | — | — | — | — |`); - continue; - } - console.log(`| ${r.n} | ${(r.full.ms / 1000).toFixed(1)} s | ${r.inc && !r.inc.error ? `${r.inc.ms.toFixed(0)} ms` : '✗'} | ${mb(r.bytes)} MB | ${((r.bytes / (16 * 1024 * 1024)) * 100).toFixed(1)}% | ${r.full.maxLagMs.toFixed(1)} ms | ${r.full.peakRssMb.toFixed(0)} MB |`); - } - } -}; - -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/client/00-collection2-init.js b/client/00-collection2-init.js deleted file mode 100644 index cf6a97e..0000000 --- a/client/00-collection2-init.js +++ /dev/null @@ -1,3 +0,0 @@ -// See server/00-collection2-init.js. collection2 v4's static.js is the eager -// entry that runs main.js (patches attachSchema) before any collection loads. -import 'meteor/aldeed:collection2/static.js'; diff --git a/client/main.js b/client/main.js index 7e98193..f139266 100644 --- a/client/main.js +++ b/client/main.js @@ -1,5 +1,3 @@ -// collection2 v4 is lazy with no main module: import the exported Collection2 -// so main.js runs and patches attachSchema before any collection loads. import '../imports/startup/client'; // https://github.com/GoogleChrome/rendertron#rendering-budget-timeout diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml deleted file mode 100644 index 74a986a..0000000 --- a/docker-compose.e2e.yml +++ /dev/null @@ -1,78 +0,0 @@ -# Stack EFÍMERO para la suite e2e (Playwright) y el smoke REST. -# -# ⚠️ Todo lo de aquí es desechable y se siembra BORRANDO colecciones -# (e2e/seed.js, smoke/seed.js). Por eso el Mongo va en tmpfs y sin volumen -# nombrado: no puede sobrevivir al `down`, y así no hay forma de que alguien lo -# confunda con el stack local (docker-compose.yml) ni con staging. NUNCA apuntes -# la suite a testfuegos.comunes.org. -# -# Uso: -# docker compose -f docker-compose.e2e.yml up -d --build -# E2E_BASE_URL=http://localhost:3300 npm --prefix e2e test -# docker compose -f docker-compose.e2e.yml down -v -# -# settings-ci.json se monta tal cual: no lleva secretos (ver el fichero). - -name: tcef-e2e - -services: - mongo: - image: mongo:7 - # replSet aunque sea de un nodo: sin oplog, Meteor cae a polling cada 10s y - # la unión de zonas tardaría eso en llegar al navegador — justo lo que los - # tests miden. - command: ["--replSet", "rs0", "--bind_ip_all"] - tmpfs: - - /data/db - healthcheck: - test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] - interval: 5s - timeout: 5s - retries: 20 - start_period: 10s - - mongo-init: - image: mongo:7 - depends_on: - mongo: - condition: service_healthy - restart: "no" - volumes: - - ./e2e/seed.js:/seed/e2e-seed.js:ro - entrypoint: - - bash - - -ec - - | - mongosh --host mongo --quiet --eval 'try { rs.status().ok } catch (e) { rs.initiate({_id: "rs0", members: [{_id: 0, host: "mongo:27017"}]}) }' - for i in $$(seq 1 30); do - state=$$(mongosh --host mongo --quiet --eval 'try { rs.isMaster().ismaster } catch (e) { false }') - [ "$$state" = "true" ] && break - sleep 1 - done - # Las migraciones históricas de Meteor (up() aún síncronos) se marcan - # como aplicadas, igual que en el compose local. - mongosh --host mongo --quiet fuegos --eval 'db.migrations.replaceOne({_id: "control"}, {_id: "control", version: 18, locked: false}, {upsert: true})' - mongosh --host mongo --quiet fuegos /seed/e2e-seed.js - echo "mongo-init: replica set + migraciones + seed OK" - - web: - build: - context: . - dockerfile: Dockerfile - image: tcef-web:e2e - depends_on: - mongo-init: - condition: service_completed_successfully - environment: - PORT: "3000" - ROOT_URL: http://localhost:3300 - MONGO_URL: mongodb://mongo:27017/fuegos?replicaSet=rs0 - MONGO_OPLOG_URL: mongodb://mongo:27017/local?replicaSet=rs0 - METEOR_SETTINGS_FILE: /run/settings-ci.json - # Usuarios de prueba (fixtures.js): el e2e crea los suyos, pero el smoke y - # la exploración manual esperan admin@admin.com / password. - TCEF_SEED: "1" - volumes: - - ./settings-ci.json:/run/settings-ci.json:ro - ports: - - "3300:3000" diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml deleted file mode 100644 index c4fca78..0000000 --- a/docker-compose.staging.yml +++ /dev/null @@ -1,172 +0,0 @@ -# Stack de STAGING de "Todos contra el fuego" para testfuegos.comunes.org. -# Derivado de docker-compose.yml (validación local). Diferencias clave: -# - web sirve en https://testfuegos.comunes.org (tras el proxy assange), puerto host 8004 -# - Mongo 7 AISLADO, sembrado con un volcado real de `fuegos` y contactos neutralizados -# (NUNCA apunta al rsmain de producción) -# - notificaciones en MODE=shadow / MATCHER_MODE=shadow: calcula y loguea, NO envía -# - mailhog captura TODO el correo (nada sale a un SMTP real) -# - node-red solo conversacional; si se prueba el bot, con un BOT DE PRUEBAS, nunca los reales -# -# Despliegue real: vía el role ansible roles/tcef_staging/ en el repo de Comunes -# (~/proyectos/sync/comunes/shared-l2/ansible/). Este compose es la fuente que ese role monta. -# Secretos: ./secrets/*.staging.* (gitignored). Uso: RUNBOOK.md. - -name: tcef-staging - -x-logging: &default-logging - driver: json-file - options: - max-size: "10m" - max-file: "3" - -services: - mongo: - image: mongo:7 - container_name: tcef-staging-mongo - command: ["--replSet", "rs0", "--bind_ip_all"] - volumes: - - mongo-data:/data/db - healthcheck: - test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] - interval: 10s - timeout: 5s - retries: 12 - start_period: 20s - restart: unless-stopped - logging: *default-logging - - # One-shot: inicia el replica set de un nodo y marca las migraciones a v18 - # (los datos reales llegan pre-migrados). Idéntico al compose de validación. - mongo-init: - image: mongo:7 - depends_on: - mongo: - condition: service_healthy - restart: "no" - entrypoint: - - bash - - -ec - - | - mongosh --host mongo --quiet --eval 'try { rs.status().ok } catch (e) { rs.initiate({_id: "rs0", members: [{_id: 0, host: "mongo:27017"}]}) }' - for i in $$(seq 1 30); do - state=$$(mongosh --host mongo --quiet --eval 'try { rs.isMaster().ismaster } catch (e) { false }') - [ "$$state" = "true" ] && break - sleep 1 - done - mongosh --host mongo --quiet fuegos --eval 'db.migrations.replaceOne({_id: "control"}, {_id: "control", version: 18, locked: false}, {upsert: true})' - echo "mongo-init: replica set + migrations OK" - logging: *default-logging - - web: - # La imagen se construye en el CI (runner de aaron) y se publica en el registry de - # Forgejo; aquí solo se descarga. groucho NO compila: un `meteor build` lo dejó sin ssh - # (5,9 GB compartidos con Mongo 7 + el resto del stack). Ver - # plan-modernizacion/fase-9-ci-imagenes.md y .forgejo/workflows/build-image.yml. - # Para fijar una versión concreta: TCEF_WEB_TAG= docker compose up -d web - image: git.comunes.org/comunes/tcef-web:${TCEF_WEB_TAG:-meteor3-wip} - container_name: tcef-staging-web - depends_on: - mongo-init: - condition: service_completed_successfully - environment: - PORT: "3000" - ROOT_URL: https://testfuegos.comunes.org - MONGO_URL: mongodb://mongo:27017/fuegos?replicaSet=rs0 - METEOR_SETTINGS_FILE: /run/secrets/meteor-settings.json - volumes: - - ./secrets/meteor-settings.staging.json:/run/secrets/meteor-settings.json:ro - # Solo el proxy (assange) llega a este puerto; se publica en la red interna del host. - ports: - - "8004:3000" - restart: unless-stopped - logging: *default-logging - - redis: - image: redis:7-alpine - container_name: tcef-staging-redis - command: ["redis-server", "--appendonly", "yes"] - volumes: - - redis-data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 6 - restart: unless-stopped - logging: *default-logging - - notifications: - build: - context: ../tcef-notifications - image: tcef-notifications:local - container_name: tcef-staging-notifications - depends_on: - mongo-init: - condition: service_completed_successfully - redis: - condition: service_healthy - # secrets/notifications.staging.env → MODE=shadow, MATCHER_MODE=shadow, sin FCM, - # MAIL_URL=smtp://mailhog:1025. No envía nada a usuarios reales. - env_file: - - ./secrets/notifications.staging.env - restart: unless-stopped - logging: *default-logging - - # Buzón SMTP de captura: TODO el correo (verificación/reset/notifs) aterriza aquí, nunca fuera. - # UI web en 127.0.0.1:8025 (acceder por túnel SSH). SMTP interno en mailhog:1025. - mailhog: - image: mailhog/mailhog:latest - container_name: tcef-staging-mailhog - environment: - MH_STORAGE: memory - ports: - - "127.0.0.1:8025:8025" - restart: unless-stopped - logging: *default-logging - - node-red: - image: nodered/node-red:4.0 - container_name: tcef-staging-node-red - volumes: - - nodered-data:/data - ports: - - "127.0.0.1:1880:1880" - healthcheck: - test: ["CMD", "wget", "-qO", "/dev/null", "http://127.0.0.1:1880/"] - interval: 30s - timeout: 5s - retries: 5 - start_period: 30s - restart: unless-stopped - logging: *default-logging - - # Importador NASA FIRMS → escribe activefires en ESTE mongo (NUNCA en prod), alimentando al - # matcher en shadow. Proceso de larga vida: hace su ciclo cada INTERVAL_MINUTES (15, igual - # que el cron de shiva), no hace falta timer del host. - # El JWT de Earthdata va en secrets/importer.staging.env, nunca en la imagen ni en el repo. - importer: - build: - context: ../todos-contra-el-fuego/nasa-importer - image: tcef-nasa-importer:local - container_name: tcef-staging-importer - depends_on: - mongo-init: - condition: service_completed_successfully - environment: - MONGO_URL: mongodb://mongo:27017/fuegos?replicaSet=rs0 - INTERVAL_MINUTES: "15" - # Reproduce el `when` de producción (acq_time UTC leído como hora local). Ver - # nasa-importer/README.md § "Zona horaria" antes de cambiarlo a UTC. - FIRE_TIME_TZ: Europe/Madrid - env_file: - - ./secrets/importer.staging.env - volumes: - - importer-data:/data - restart: unless-stopped - logging: *default-logging - -volumes: - mongo-data: - redis-data: - nodered-data: - importer-data: diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index f83c85a..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,125 +0,0 @@ -# Stack local modernizado de "Todos contra el fuego" (fase 3 — validación local). -# NO es el despliegue de producción: eso irá al ansible de Comunes (proxies, -# firewall, monitorización). Aquí se valida que todo el stack corre en Docker. -# -# Uso: ver RUNBOOK.md. Secretos: ./secrets/ (montados como ficheros, gitignored). - -name: tcef-stack - -x-logging: &default-logging - driver: json-file - options: - max-size: "10m" - max-file: "3" - -services: - mongo: - image: mongo:7 - container_name: tcef-stack-mongo - command: ["--replSet", "rs0", "--bind_ip_all"] - volumes: - - mongo-data:/data/db - healthcheck: - test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] - interval: 10s - timeout: 5s - retries: 12 - start_period: 20s - restart: unless-stopped - logging: *default-logging - - # One-shot: inicia el replica set (un nodo) y marca las migraciones de Meteor - # como aplicadas (v18) para que los up() históricos (aún síncronos) no corran. - mongo-init: - image: mongo:7 - depends_on: - mongo: - condition: service_healthy - restart: "no" - entrypoint: - - bash - - -ec - - | - mongosh --host mongo --quiet --eval 'try { rs.status().ok } catch (e) { rs.initiate({_id: "rs0", members: [{_id: 0, host: "mongo:27017"}]}) }' - # espera a que el nodo sea PRIMARY antes de escribir - for i in $$(seq 1 30); do - state=$$(mongosh --host mongo --quiet --eval 'try { rs.isMaster().ismaster } catch (e) { false }') - [ "$$state" = "true" ] && break - sleep 1 - done - mongosh --host mongo --quiet fuegos --eval 'db.migrations.replaceOne({_id: "control"}, {_id: "control", version: 18, locked: false}, {upsert: true})' - echo "mongo-init: replica set + migrations OK" - logging: *default-logging - - web: - build: - context: . - dockerfile: Dockerfile - image: tcef-web:meteor3 - container_name: tcef-stack-web - depends_on: - mongo-init: - condition: service_completed_successfully - environment: - PORT: "3000" - ROOT_URL: http://localhost:3200 - MONGO_URL: mongodb://mongo:27017/fuegos?replicaSet=rs0 - METEOR_SETTINGS_FILE: /run/secrets/meteor-settings.json - volumes: - - ./secrets/meteor-settings.json:/run/secrets/meteor-settings.json:ro - ports: - - "3200:3000" - restart: unless-stopped - logging: *default-logging - - redis: - image: redis:7-alpine - container_name: tcef-stack-redis - command: ["redis-server", "--appendonly", "yes"] - volumes: - - redis-data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 6 - restart: unless-stopped - logging: *default-logging - - notifications: - build: - context: ../tcef-notifications - image: tcef-notifications:local - container_name: tcef-stack-notifications - depends_on: - mongo-init: - condition: service_completed_successfully - redis: - condition: service_healthy - # Copia secrets/notifications.env.example -> secrets/notifications.env. - # Por defecto MODE=dry-run: no envía nada aunque haya credenciales. - env_file: - - ./secrets/notifications.env - restart: unless-stopped - logging: *default-logging - - node-red: - image: nodered/node-red:4.0 - container_name: tcef-stack-node-red - volumes: - - nodered-data:/data - ports: - - "1880:1880" - healthcheck: - test: ["CMD", "wget", "-qO", "/dev/null", "http://127.0.0.1:1880/"] - interval: 30s - timeout: 5s - retries: 5 - start_period: 30s - restart: unless-stopped - logging: *default-logging - -volumes: - mongo-data: - redis-data: - nodered-data: diff --git a/docker/web-entrypoint.sh b/docker/web-entrypoint.sh deleted file mode 100644 index 8d3ff5a..0000000 --- a/docker/web-entrypoint.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh -# Carga METEOR_SETTINGS desde un fichero montado (secretos fuera de la imagen). -set -e - -if [ -n "${METEOR_SETTINGS_FILE:-}" ]; then - if [ ! -r "$METEOR_SETTINGS_FILE" ]; then - echo "ERROR: METEOR_SETTINGS_FILE=$METEOR_SETTINGS_FILE no existe o no es legible" >&2 - exit 1 - fi - METEOR_SETTINGS="$(cat "$METEOR_SETTINGS_FILE")" - export METEOR_SETTINGS -fi - -exec "$@" diff --git a/e2e/.gitignore b/e2e/.gitignore deleted file mode 100644 index 100897a..0000000 --- a/e2e/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules/ -playwright-report/ -test-results/ -qa-shots/ diff --git a/e2e/README.md b/e2e/README.md deleted file mode 100644 index 78f6606..0000000 --- a/e2e/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# Suite e2e (Playwright) - -Cubre en navegador los flujos que la suite de servidor no puede ver: registro y -acceso, alta y baja de una zona **desde el mapa**, que la unión de zonas se pinte -en `/subscriptions` y en `/zones`, comentario en el detalle de un fuego, y cambio -de idioma. - -El motivo de que exista: el 2026-07-30 suscribirse a una zona congeló staging -(la unión bloqueaba el event loop y DDP dejaba de responder). Ningún test de -servidor habría visto eso — el navegador sí. - -## ⚠️ Contra qué se puede correr - -Solo contra un stack **desechable**: `docker-compose.e2e.yml` o un servidor de -desarrollo local. La siembra (`e2e/seed.js`) **borra colecciones**, y los tests -crean usuarios y zonas. `playwright.config.js` se niega a arrancar si -`E2E_BASE_URL` apunta a `testfuegos.comunes.org` o a los dominios de producción. - -## Uso - -Stack efímero completo (lo mismo que hace el CI): - -```bash -docker compose -f docker-compose.e2e.yml up -d --build -npm --prefix e2e ci -npx --prefix e2e playwright install --with-deps chromium -E2E_BASE_URL=http://localhost:3300 npm --prefix e2e test -docker compose -f docker-compose.e2e.yml down -v -``` - -Contra un servidor de desarrollo (ciclo rápido mientras se escriben tests): - -```bash -docker run -d --name tcef-e2e-mongo -p 27021:27017 --tmpfs /data/db mongo:7 --replSet rs0 --bind_ip_all -docker exec tcef-e2e-mongo mongosh --quiet --eval 'rs.initiate({_id:"rs0",members:[{_id:0,host:"localhost:27017"}]})' -docker cp e2e/seed.js tcef-e2e-mongo:/tmp/seed.js && docker exec tcef-e2e-mongo mongosh --quiet fuegos /tmp/seed.js - -export PATH="$HOME/.meteor:$PATH" -MONGO_URL='mongodb://localhost:27021/fuegos?replicaSet=rs0&directConnection=true' \ -MONGO_OPLOG_URL='mongodb://localhost:27021/local?replicaSet=rs0&directConnection=true' \ -ROOT_URL=http://localhost:3100 meteor --settings settings-ci.json --port 3100 - -npm --prefix e2e test # E2E_BASE_URL por defecto: http://localhost:3100 -``` - -Ver el informe del último fallo: `npm --prefix e2e run report`. - -## Dependencias externas - -El navegador carga las teselas de CartoDB y el script de Google Maps (el -autocompletado de lugares). El runner necesita salida a internet; sin ella los -mapas no llegan a pintarse y los tests de zona fallan. La clave de Google puede -ir vacía: `Gkeys` ya no se queda esperándola. - -## Lo que la suite documenta como roto - -`i18n.spec.js` deja un `test.fixme` para un bug real: el idioma elegido en el -perfil **no sobrevive a una recarga** (se guarda en la cuenta y se aplica al -iniciar sesión, pero al recargar vuelve al idioma del navegador). Está ahí para -que aparezca en cada ejecución en vez de perderse en una nota. diff --git a/e2e/package-lock.json b/e2e/package-lock.json deleted file mode 100644 index 25508f8..0000000 --- a/e2e/package-lock.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "name": "tcef-e2e", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "tcef-e2e", - "devDependencies": { - "@playwright/test": "^1.49.1" - } - }, - "node_modules/@playwright/test": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", - "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.62.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/playwright": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", - "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.62.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", - "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=20" - } - } - } -} diff --git a/e2e/package.json b/e2e/package.json deleted file mode 100644 index bb7aaac..0000000 --- a/e2e/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "tcef-e2e", - "private": true, - "description": "Browser end-to-end suite for todos-contra-el-fuego-web. Kept out of the app's package.json on purpose: Meteor should never see these dependencies.", - "type": "module", - "scripts": { - "test": "playwright test", - "test:headed": "playwright test --headed", - "report": "playwright show-report", - "install-browsers": "playwright install --with-deps chromium" - }, - "devDependencies": { - "@playwright/test": "^1.49.1" - } -} diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js deleted file mode 100644 index d7e8e99..0000000 --- a/e2e/playwright.config.js +++ /dev/null @@ -1,42 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -// E2E_BASE_URL points at a THROWAWAY stack — the compose in docker-compose.e2e.yml -// or a local dev server. Never at testfuegos.comunes.org or anything else with -// data in it: these tests create users and subscriptions, and the seed step wipes -// collections. -const baseURL = process.env.E2E_BASE_URL || 'http://localhost:3100'; - -if (/testfuegos|fuegos\.comunes|fires\.comunes/.test(baseURL)) { - throw new Error(`Refusing to run the e2e suite against ${baseURL}: it seeds and writes data.`); -} - -export default defineConfig({ - testDir: './tests', - // Meteor's first page load compiles and ships a big bundle; the map then waits - // for tiles and DDP subscriptions. - timeout: 90 * 1000, - expect: { timeout: 20 * 1000 }, - // Every spec creates its own user, but they share one Mongo and one union - // recompute queue; running them in parallel makes failures much harder to read - // for very little wall-clock on a runner that only has one job slot anyway. - workers: 1, - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 1 : 0, - reporter: process.env.CI - ? [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]] - : [['list']], - use: { - baseURL, - // Both on first retry only: a trace per test would be gigabytes of artifacts - // for a suite that is green most of the time. - trace: 'retain-on-failure', - screenshot: 'only-on-failure', - video: 'retain-on-failure', - actionTimeout: 20 * 1000, - locale: 'es-ES' - }, - projects: [ - { name: 'chromium', use: { ...devices['Desktop Chrome'] } } - ] -}); diff --git a/e2e/seed.js b/e2e/seed.js deleted file mode 100644 index 5d682b9..0000000 --- a/e2e/seed.js +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Deterministic seed for the browser e2e suite (mongosh script). - * - * ⚠️ DESTRUCTIVE: it empties the collections it seeds. It is only ever meant for - * the throwaway Mongo of docker-compose.e2e.yml (or a local scratch one). Never - * point it at staging or production — that is what e2e/seed.sh guards against. - * - * mongosh --host localhost:27021 fuegos e2e/seed.js - * - * The subscriptions collection is deliberately NOT seeded: the whole point of - * the zone spec is to create one through the UI and watch the union appear. - */ - -var FIRE_A = ObjectId('e2e0000000000000000000a1'); -var FIRE_B = ObjectId('e2e0000000000000000000b2'); - -// Madrid, so a zone created around the map's default view sees them. -var LAT = 40.41; -var LON = -3.70; -var WHEN = ISODate('2026-07-30T05:30:00.000Z'); -var WHEN_B = ISODate('2026-07-30T06:30:00.000Z'); -var FIXED = ISODate('2026-07-30T00:00:00.000Z'); - -db.activefires.deleteMany({}); -db.activefires.insertMany([ - { - _id: FIRE_A, - ourid: { type: 'Point', coordinates: [LON, LAT] }, - lat: LAT, lon: LON, type: 'viirs', when: WHEN, - scan: 0.5, track: 0.4, acq_date: '2026-07-30', acq_time: '05:30', - satellite: 'N', confidence: 50, version: '1.0NRT', frp: 4.3, - daynight: 'N', bright_ti4: 330.2, bright_ti5: 291, - createdAt: FIXED, updatedAt: FIXED - }, - { - _id: FIRE_B, - ourid: { type: 'Point', coordinates: [LON + 0.02, LAT + 0.02] }, - lat: LAT + 0.02, lon: LON + 0.02, type: 'viirs', when: WHEN_B, - scan: 0.5, track: 0.4, acq_date: '2026-07-30', acq_time: '06:30', - satellite: 'N', confidence: 60, version: '1.0NRT', frp: 6.1, - daynight: 'N', bright_ti4: 331.0, bright_ti5: 292, - createdAt: FIXED, updatedAt: FIXED - } -]); -db.activefires.createIndex({ ourid: '2dsphere' }); - -db.activefiresunion.deleteMany({}); -db.activefiresunion.createIndex({ shape: '2dsphere' }); - -db.comments.deleteMany({}); -db.falsePositives.deleteMany({}); -db.industries.deleteMany({}); - -// Anything left over from a previous run of the suite: users it created, their -// zones, and the stored union computed from them. -db.subscriptions.deleteMany({}); -db.users.deleteMany({ 'emails.address': /^e2e\+/ }); -db.siteSettings.deleteMany({ name: /^subs-(public|private)-union/ }); -db.siteSettings.deleteMany({ name: 'subs-union-count' }); - -print('e2e seed OK: ' + db.activefires.countDocuments({}) + ' active fires, 0 subscriptions'); diff --git a/e2e/staging-qa.mjs b/e2e/staging-qa.mjs deleted file mode 100644 index ffe48ca..0000000 --- a/e2e/staging-qa.mjs +++ /dev/null @@ -1,295 +0,0 @@ -#!/usr/bin/env node -/* - * Checklist de QA de staging (fase 13), ejecutable y repetible. - * - * NO es la suite e2e: aquella siembra la base (borra colecciones) y por eso su - * configuración se niega a apuntar a staging. Esto es lo contrario — solo mira, - * y con `--write` además crea UNA cuenta y UNA zona, que es lo que la fase pide - * probar a mano. No toca datos de nadie más. - * - * node e2e/staging-qa.mjs # solo lectura - * node e2e/staging-qa.mjs --write # + registro y alta/baja de zona - * QA_URL=https://testfuegos.comunes.org node e2e/staging-qa.mjs - * - * Cada comprobación imprime OK/FALLO y, al final, un resumen. Las capturas de - * los fallos quedan en e2e/qa-shots/. - */ - -import fs from 'node:fs'; -import path from 'node:path'; -import { chromium } from '@playwright/test'; - -const BASE = (process.env.QA_URL || 'https://testfuegos.comunes.org').replace(/\/$/, ''); -const WRITE = process.argv.includes('--write'); -// Id hexadecimal de un activefire para probar la página de detalle. Se saca en -// solo lectura del propio staging; sin él esa parte se salta. -const FIRE = process.env.QA_FIRE || ''; -const SHOTS = path.resolve(import.meta.dirname, 'qa-shots'); - -// Comparación de host exacta, no un `endsWith`: testfuegos.comunes.org también -// termina en fuegos.comunes.org y es justo donde SÍ hay que ejecutar esto. -const PROD_HOSTS = ['fuegos.comunes.org', 'fires.comunes.org']; -if (PROD_HOSTS.includes(new URL(BASE).hostname)) { - console.error(`Me niego a hacer QA contra producción (${BASE}).`); - process.exit(1); -} - -// ⚠️ NADA de selectores por id: `imports/ui/components/Utils/TestUtils.js` -// devuelve el id solo en desarrollo, así que en staging y producción los -// elementos NO lo llevan. Aquí se va por href, rol y texto, que es lo que hay -// en los tres sitios. -const NAV = { - zonas: 'nav a[href="/zones"]', - fuegos: 'nav a[href="/fires"]', - perfil: 'nav a[href="/profile"]', - entrar: 'nav a[href="/login"]', - salir: 'nav a[href="/logout"]', - misZonas: 'nav a[href="/subscriptions"]' -}; - -const results = []; -let page; - -const check = async (name, fn) => { - try { - const detail = await fn(); - results.push({ name, ok: true, detail }); - console.log(` OK ${name}${detail ? ` — ${detail}` : ''}`); - } catch (e) { - results.push({ name, ok: false, detail: e.message }); - console.log(` FALLO ${name} — ${e.message}`); - try { - fs.mkdirSync(SHOTS, { recursive: true }); - await page.screenshot({ path: path.join(SHOTS, `${name.replace(/[^a-z0-9]+/gi, '-')}.png`), fullPage: true }); - } catch (_) { /* la captura es un extra */ } - } -}; - -const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; - -// La página es una SPA: tras `domcontentloaded` el body está casi vacío hasta -// que React pinta. Sin esperar a esto, medio checklist daba falsos fallos. -const rendered = async (minChars = 400) => { - await page.waitForFunction( - n => document.body && document.body.innerText.trim().length > n, - minChars, - { timeout: 45000 } - ).catch(() => { throw new Error('la página no llegó a pintarse'); }); - return (await page.locator('body').innerText()).length; -}; - -const visible = async (selector, msg) => { - const el = page.locator(selector).first(); - await el.waitFor({ state: 'visible', timeout: 30000 }).catch(() => { - throw new Error(msg || `no aparece ${selector}`); - }); - return el; -}; - -const mapReady = async () => { - await visible('.leaflet-container', 'el mapa no llegó a montarse'); - await page.waitForTimeout(2500); - const tiles = await page.locator('.leaflet-tile-loaded').count(); - assert(tiles > 0, 'el mapa monta pero no carga ninguna tesela'); - return `${tiles} teselas`; -}; - -const subscriptionNames = () => page.evaluate(() => (window.Meteor - ? Object.values(window.Meteor.connection._subscriptions).map(s => s.name) - : [])); - -const main = async () => { - console.log(`QA de ${BASE}${WRITE ? ' (con escritura)' : ' (solo lectura)'}\n`); - const browser = await chromium.launch(); - const context = await browser.newContext({ locale: 'es-ES' }); - page = await context.newPage(); - const consoleErrors = []; - page.on('pageerror', e => consoleErrors.push(String(e).slice(0, 200))); - - console.log('— Portada y mapas'); - await check('la portada carga', async () => { - const res = await page.goto(BASE, { waitUntil: 'domcontentloaded' }); - assert(res.ok(), `HTTP ${res.status()}`); - await visible('nav'); - await rendered(); - return `HTTP ${res.status()}`; - }); - await check('la portada monta sus mapas', mapReady); - - await check('/fires pinta el mapa de fuegos activos', async () => { - await page.goto(`${BASE}/fires`, { waitUntil: 'domcontentloaded' }); - return mapReady(); - }); - await check('/fires se suscribe a activefiresmyloc', async () => { - const names = await subscriptionNames(); - assert(names.includes('activefiresmyloc'), `suscripciones presentes: ${names.join(', ') || 'ninguna'}`); - return names.filter(n => n.startsWith('activefires')).join(', '); - }); - await check('el contador de fuegos activos no dice NaN', async () => { - const text = await page.locator('body').innerText(); - assert(!/NaN/.test(text), 'aparece NaN en la página'); - const m = text.match(/(\d+)\s+fuegos activos/i); - return m ? `${m[1]} fuegos` : 'sin contador visible'; - }); - await check('conmuta la capa base del mapa', async () => { - // Hover sobre el contenedor, no sobre el botón: al desplegarse, la lista se - // pone encima del botón y le roba el puntero. - await page.locator('.leaflet-control-layers').first().hover(); - await page.waitForTimeout(500); - const options = await page.locator('.leaflet-control-layers-base label').count(); - assert(options >= 2, `solo ${options} capa(s) base`); - await page.locator('.leaflet-control-layers-base input').nth(1).check({ force: true }); - await page.waitForTimeout(2000); - return `${options} capas base`; - }); - - await check('/zones pinta el mapa de zonas vigiladas', async () => { - await page.goto(`${BASE}/zones`, { waitUntil: 'domcontentloaded' }); - return mapReady(); - }); - await check('/zones pinta la unión de suscripciones', async () => { - const paths = await page.locator('.leaflet-overlay-pane path').count(); - assert(paths > 0, 'no hay ningún polígono en la capa de superposición'); - return `${paths} polígonos`; - }); - - console.log('\n— Detalle de fuego y comentarios'); - // Por id, no clicando una marca del mapa: mientras /fires siga en gris, ese - // camino no existe, y el detalle del fuego se puede probar igualmente. - if (!FIRE) { - console.log(' (saltado) sin QA_FIRE= no se puede abrir un detalle'); - } else { - await check('el detalle de un fuego abre con su mapa', async () => { - await page.goto(`${BASE}/fire/active/${FIRE}`, { waitUntil: 'domcontentloaded' }); - await visible('h4.page-header'); - await rendered(); - await mapReady(); - return page.url().replace(BASE, ''); - }); - await check('el detalle canonicaliza la URL al archivo', async () => { - assert(/\/fire\/archive\/[0-9a-f]{24}$/.test(page.url()), `URL inesperada: ${page.url()}`); - return page.url().replace(BASE, ''); - }); - await check('el detalle ofrece comentarios', async () => { - const text = await page.locator('body').innerText(); - assert(/Comentarios/i.test(text), 'no aparece la sección de comentarios'); - return 'sección presente'; - }); - } - - console.log('\n— Idioma y cookies'); - await check('el banner de cookies aparece y se acepta', async () => { - const accept = page.getByRole('button', { name: /Aceptar/i }).first(); - if (await accept.count() === 0) return 'ya aceptado en esta sesión'; - await accept.click(); - await page.waitForTimeout(500); - return 'aceptado'; - }); - await check('un navegador en inglés ve la web en inglés', async () => { - const enContext = await browser.newContext({ locale: 'en-US' }); - const enPage = await enContext.newPage(); - await enPage.goto(BASE, { waitUntil: 'domcontentloaded' }); - await enPage.waitForFunction((sel) => { - const el = document.querySelector(sel); - return el && el.textContent.trim().length > 0; - }, NAV.zonas, { timeout: 45000 }).catch(() => {}); - const label = await enPage.locator(NAV.zonas).innerText().catch(() => ''); - await enContext.close(); - assert(/Monitored/i.test(label), `la navegación sigue en español: "${label}"`); - return `enlace de zonas = "${label}"`; - }); - await check('el mismo servidor sirve español a un navegador en español', async () => { - await page.goto(BASE, { waitUntil: 'domcontentloaded' }); - await rendered(); - const label = await page.locator(NAV.zonas).innerText(); - assert(/Zonas/i.test(label), `esperaba español, salió "${label}"`); - return `enlace de zonas = "${label}"`; - }); - - console.log('\n— Páginas estáticas'); - for (const p of ['/about', '/terms', '/privacy', '/license', '/credits']) { - // eslint-disable-next-line no-await-in-loop - await check(`${p} responde`, async () => { - const res = await page.goto(BASE + p, { waitUntil: 'domcontentloaded' }); - assert(res.ok(), `HTTP ${res.status()}`); - const chars = await rendered(600); - return `${chars} caracteres`; - }); - } - - if (WRITE) { - console.log('\n— Registro y zona (escribe en staging)'); - const email = `qa+${Date.now()}@invalid.test`; - await check('registro de una cuenta nueva', async () => { - await page.goto(`${BASE}/signup`, { waitUntil: 'domcontentloaded' }); - await page.fill('input[name=firstName]', 'QA'); - await page.fill('input[name=lastName]', 'Fase13'); - await page.fill('input[name=emailAddress]', email); - await page.fill('input[name=password]', 'password-qa-13'); - await page.check('input[name=tos]'); - await page.getByRole('button', { name: 'Registrarse', exact: true }).click(); - await page.waitForURL('**/subscriptions', { timeout: 60000 }); - return email; - }); - await check('alta de una zona desde el mapa', async () => { - await mapReady(); - await page.getByRole('button', { name: 'Añadir zona' }).click(); - await page.waitForURL('**/subscriptions/new', { timeout: 30000 }); - await mapReady(); - const box = await page.locator('.leaflet-container').first().boundingBox(); - await page.mouse.click(box.x + (box.width / 2) + 20, box.y + (box.height / 2) + 20); - await page.waitForTimeout(1000); - const started = Date.now(); - await page.getByRole('button', { name: /Suscribirme/ }).click(); - await page.waitForURL('**/subscriptions', { timeout: 120000 }); - return `${((Date.now() - started) / 1000).toFixed(1)} s en responder`; - }); - await check('la zona nueva aparece pintada', async () => { - await mapReady(); - const paths = await page.locator('.leaflet-overlay-pane path').count(); - assert(paths > 0, 'no se pinta ninguna zona tras el alta'); - return `${paths} polígonos`; - }); - await check('la web sigue respondiendo tras el alta', async () => { - const started = Date.now(); - await page.goto(`${BASE}/fires`, { waitUntil: 'domcontentloaded' }); - await mapReady(); - return `${((Date.now() - started) / 1000).toFixed(1)} s en cargar /fires`; - }); - await check('baja de la zona', async () => { - await page.goto(`${BASE}/subscriptions`, { waitUntil: 'domcontentloaded' }); - await mapReady(); - await page.getByRole('button', { name: /Editar/i }).click(); - await page.locator('.leaflet-marker-icon').first().click(); - await page.getByRole('button', { name: 'Sí', exact: true }).click(); - await visible('text=No estás suscrito a fuegos en ninguna zona', 'la zona no llegó a borrarse'); - return 'borrada'; - }); - await check('cierre de sesión', async () => { - await page.locator(NAV.salir).click(); - await visible(NAV.entrar, 'no vuelve a aparecer el enlace de iniciar sesión'); - return 'sesión cerrada'; - }); - } - - console.log('\n— Errores de JavaScript'); - await check('ninguna página lanzó una excepción', async () => { - assert(consoleErrors.length === 0, `${consoleErrors.length}: ${consoleErrors.slice(0, 3).join(' | ')}`); - return 'sin excepciones'; - }); - - await browser.close(); - - const failed = results.filter(r => !r.ok); - console.log(`\n${results.length - failed.length}/${results.length} comprobaciones en verde`); - if (failed.length) { - console.log('Fallos:'); - failed.forEach(f => console.log(` - ${f.name}: ${f.detail}`)); - process.exit(1); - } -}; - -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/e2e/support/app.js b/e2e/support/app.js deleted file mode 100644 index 546fcc8..0000000 --- a/e2e/support/app.js +++ /dev/null @@ -1,71 +0,0 @@ -// Shared helpers. Everything here drives the UI the way a person would: the -// point of this suite is the wiring between browser, DDP and Mongo, so reaching -// into Meteor.call() from the page would defeat it. -// -// ⚠️ NADA de selectores por id. `imports/ui/components/Utils/TestUtils.js` -// devuelve el id SOLO en desarrollo, así que contra un bundle de producción -// —que es justo lo que levanta el job nocturno de CI— esos elementos no lo -// llevan. Aquí se va por href, rol y nombre de campo, que existen en los dos. - -export const NAV = { - zonas: 'nav a[href="/zones"]', - fuegos: 'nav a[href="/fires"]', - perfil: 'nav a[href="/profile"]', - entrar: 'nav a[href="/login"]', - salir: 'nav a[href="/logout"]', - misZonas: 'nav a[href="/subscriptions"]' -}; - -export const botonRegistro = page => page.getByRole('button', { name: 'Registrarse', exact: true }); -export const botonEntrar = page => page.getByRole('button', { name: 'Iniciar sesión', exact: true }); - -// A fresh address per test: the seed removes every e2e+* user, and reusing one -// across specs makes failures depend on the order they ran in. -export const newEmail = () => `e2e+${Date.now()}${Math.floor(Math.random() * 1000)}@test.com`; - -export const signUp = async (page, email = newEmail()) => { - await page.goto('/signup'); - await page.fill('input[name=firstName]', 'E2E'); - await page.fill('input[name=lastName]', 'Tester'); - await page.fill('input[name=emailAddress]', email); - await page.fill('input[name=password]', 'password'); - await page.check('input[name=tos]'); - await botonRegistro(page).click(); - // Signing up lands on the zones page. - await page.waitForURL('**/subscriptions', { timeout: 30000 }); - return email; -}; - -export const logIn = async (page, email, password = 'password') => { - await page.goto('/login'); - await page.fill('input[name=emailAddress]', email); - await page.fill('input[name=password]', password); - await botonEntrar(page).click(); -}; - -// The map only settles once leaflet has laid out its panes and the tiles for the -// current viewport are in. -export const waitForMap = async (page) => { - await page.locator('.leaflet-container').first().waitFor({ state: 'visible', timeout: 60000 }); - await page.waitForTimeout(1500); -}; - -// Creates a zone through the real flow: the button on the zones map, the -// selection map, and the subscribe button. This is the flow that froze staging. -export const addZoneThroughTheMap = async (page) => { - await page.goto('/subscriptions'); - await waitForMap(page); - await page.getByRole('button', { name: 'Añadir zona' }).click(); - await page.waitForURL('**/subscriptions/new', { timeout: 30000 }); - await waitForMap(page); - - const map = page.locator('.leaflet-container').first(); - const box = await map.boundingBox(); - // Click slightly off-centre: the marker moves there, which also proves the - // map is interactive and not a dead tile layer. - await page.mouse.click(box.x + (box.width / 2) + 20, box.y + (box.height / 2) + 20); - await page.waitForTimeout(1000); - - await page.getByRole('button', { name: /Suscribirme/ }).click(); - await page.waitForURL('**/subscriptions', { timeout: 60000 }); -}; diff --git a/e2e/tests/auth.spec.js b/e2e/tests/auth.spec.js deleted file mode 100644 index cf11006..0000000 --- a/e2e/tests/auth.spec.js +++ /dev/null @@ -1,44 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { signUp, logIn, newEmail, NAV, botonRegistro, botonEntrar } from '../support/app.js'; - -test.describe('registro y acceso', () => { - test('a new account can sign up, log out and log back in', async ({ page }) => { - const email = newEmail(); - await signUp(page, email); - - // Signed in: the navbar swaps the login links for the profile and logout ones. - await expect(page.locator(NAV.salir)).toBeVisible(); - await expect(page.locator(NAV.perfil)).toContainText('E2E Tester'); - - await page.locator(NAV.salir).click(); - await expect(page.locator(NAV.entrar)).toBeVisible(); - - await logIn(page, email); - await expect(page.locator(NAV.salir)).toBeVisible(); - }); - - test('signing up is refused without accepting the terms', async ({ page }) => { - await page.goto('/signup'); - await page.fill('input[name=firstName]', 'E2E'); - await page.fill('input[name=lastName]', 'Tester'); - await page.fill('input[name=emailAddress]', newEmail()); - await page.fill('input[name=password]', 'password'); - - await expect(botonRegistro(page)).toBeDisabled(); - }); - - test('a wrong password does not sign anybody in', async ({ page }) => { - const email = newEmail(); - await signUp(page, email); - await page.locator(NAV.salir).click(); - - await logIn(page, email, 'not-the-password'); - await expect(botonEntrar(page)).toBeVisible(); - await expect(page.locator(NAV.salir)).toHaveCount(0); - }); - - test('the private zones page sends anonymous visitors to the login', async ({ page }) => { - await page.goto('/subscriptions'); - await expect(botonEntrar(page)).toBeVisible(); - }); -}); diff --git a/e2e/tests/fire-comment.spec.js b/e2e/tests/fire-comment.spec.js deleted file mode 100644 index 98bd9b8..0000000 --- a/e2e/tests/fire-comment.spec.js +++ /dev/null @@ -1,40 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { signUp, waitForMap } from '../support/app.js'; - -// e2e/seed.js puts this one in activefires. Visiting an active fire archives it -// and canonicalizes the URL to /fire/archive/, which is itself worth -// pinning: it is the link every notification and every tweet points at. -const SEEDED_FIRE = '/fire/active/e2e0000000000000000000a1'; - -test.describe('detalle de un fuego y comentarios', () => { - test('a seeded fire opens, keeps its map and canonicalizes its URL', async ({ page }) => { - await page.goto(SEEDED_FIRE); - await expect(page.getByText(/Información adicional sobre fuego detectado/)).toBeVisible(); - await expect(page).toHaveURL(/\/fire\/archive\/[0-9a-f]{24}$/); - await waitForMap(page); - await expect(page.getByText('Coordenadas: 40.41, -3.7')).toBeVisible(); - }); - - test('a signed-in user can comment on a fire and sees it appear', async ({ page }) => { - await signUp(page); - await page.goto(SEEDED_FIRE); - await expect(page.getByText('Comentarios')).toBeVisible(); - - const comment = `Ardió el pinar de la ladera norte ${Date.now()}`; - await page.locator('textarea.create-comment').fill(comment); - await page.getByRole('button', { name: 'Añadir comentario' }).click(); - - await expect(page.getByText(comment)).toBeVisible({ timeout: 30000 }); - - // And it is really stored, not just echoed into the DOM. - await page.reload(); - await expect(page.getByText(comment)).toBeVisible({ timeout: 30000 }); - }); - - test('an anonymous visitor is asked to sign in instead of getting a comment box', async ({ page }) => { - await page.goto(SEEDED_FIRE); - await expect(page.getByRole('heading', { name: 'Comentarios' })).toBeVisible(); - await expect(page.getByText('Necesitas iniciar sesión para añadir comentarios')).toBeVisible(); - await expect(page.locator('textarea.create-comment')).toHaveCount(0); - }); -}); diff --git a/e2e/tests/fires-map.spec.js b/e2e/tests/fires-map.spec.js deleted file mode 100644 index 675c293..0000000 --- a/e2e/tests/fires-map.spec.js +++ /dev/null @@ -1,61 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { waitForMap } from '../support/app.js'; - -// El "mapa gris" de /fires: en staging la página no pintaba ninguna tesela, no -// creaba la suscripción por viewport y se quedaba en "Actualizando…" para -// siempre. Causa, encontrada reproduciéndolo con el bundle de producción en -// local (con el servidor de desarrollo NO se ve): -// -// Tracker.autorun(() => { -// if ((centerStored !== [0, 0] || geolocation.get()) && geoInit) { -// center.set(centerStored || geolocation.get()); -// geoInit = false; -// } -// -// `centerStored !== [0, 0]` compara contra un array recién creado, así que es -// SIEMPRE cierto. En un navegador nuevo —sin centro en localStorage y con la -// geolocalización aún sin resolver— eso hacía `center.set(undefined)` y dejaba -// `geoInit` en false, o sea para siempre. Un `` sin `center` no -// recibe `setView`, y un mapa de Leaflet sin vista no carga capa base ni -// teselas ni dispara `whenReady`, con lo que tampoco se crea la suscripción por -// viewport. En desarrollo no se veía porque la geolocalización ya estaba -// resuelta cuando el mapa se montaba. -// -// ⚠️ Estos tests solo muerden contra un bundle de PRODUCCIÓN, que es lo que -// levanta el job nocturno de CI. Contra el servidor de desarrollo pasan igual. -test.describe('mapa de fuegos activos', () => { - test('paints its tiles', async ({ page }) => { - await page.goto('/fires'); - await waitForMap(page); - - await expect(page.locator('.leaflet-tile-loaded').first()).toBeVisible({ timeout: 60000 }); - expect(await page.locator('.leaflet-tile').count()).toBeGreaterThan(0); - }); - - test('keeps exactly one base layer, and it is on the map', async ({ page }) => { - await page.goto('/fires'); - await waitForMap(page); - - expect(await page.locator('.leaflet-control-layers-base input').count()).toBeGreaterThanOrEqual(2); - expect(await page.locator('.leaflet-control-layers-base input:checked').count()).toEqual(1); - // Marcada en el control pero ausente del mapa es justo el síntoma del bug. - expect(await page.locator('.leaflet-tile-pane .leaflet-layer').count()).toBeGreaterThan(0); - }); - - test('subscribes to the fires of the current viewport', async ({ page }) => { - await page.goto('/fires'); - await waitForMap(page); - - const names = await page.evaluate(() => Object.values(window.Meteor.connection._subscriptions).map(s => s.name)); - expect(names).toContain('activefiresmyloc'); - expect(names).toContain('activefiresunionmyloc'); - }); - - test('does not get stuck on "Actualizando…"', async ({ page }) => { - await page.goto('/fires'); - await waitForMap(page); - await page.waitForTimeout(5000); - - await expect(page.getByText('Actualizando...', { exact: false })).toHaveCount(0); - }); -}); diff --git a/e2e/tests/i18n.spec.js b/e2e/tests/i18n.spec.js deleted file mode 100644 index 5e556dd..0000000 --- a/e2e/tests/i18n.spec.js +++ /dev/null @@ -1,54 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { signUp, NAV } from '../support/app.js'; - -// Through the navbar, not page.goto('/profile'): a cold load of that URL bounces -// to /subscriptions (the Authenticated wrapper decides before Meteor has resumed -// the login token). Worth its own fix, but it is not what this spec is about. -const openProfile = async (page) => { - await page.locator(NAV.perfil).click(); - await page.waitForURL('**/profile', { timeout: 30000 }); -}; - -const chooseLanguage = async (page, name) => { - await page.locator('.lang-selector').first().click(); - await page.getByRole('button', { name }).click(); -}; - -// The site is bilingual (es/en) and the language belongs to the account, not to -// the browser session: notifications and emails go out in it too. -test.describe('cambio de idioma', () => { - test('the interface follows the language chosen in the profile', async ({ page }) => { - await signUp(page); - await expect(page.locator(NAV.zonas)).toHaveText('Zonas vigiladas'); - - await openProfile(page); - await chooseLanguage(page, 'English'); - - await expect(page.locator(NAV.zonas)).toHaveText('Monitored areas', { timeout: 30000 }); - }); - - test('going back to Spanish works too', async ({ page }) => { - await signUp(page); - await openProfile(page); - await chooseLanguage(page, 'English'); - await expect(page.locator(NAV.zonas)).toHaveText('Monitored areas', { timeout: 30000 }); - - await chooseLanguage(page, 'Español'); - await expect(page.locator(NAV.zonas)).toHaveText('Zonas vigiladas', { timeout: 30000 }); - }); - - // KNOWN BUG, kept as a failing-by-design test instead of being quietly dropped: - // users.setLang does store the choice on the account, and Login.js applies it - // when you log in — but a plain reload of an already-logged-in session falls - // back to the browser's locale. Someone who picked English sees Spanish again - // on the next page load. - test.fixme('the chosen language survives a reload', async ({ page }) => { - await signUp(page); - await openProfile(page); - await chooseLanguage(page, 'English'); - await expect(page.locator(NAV.zonas)).toHaveText('Monitored areas', { timeout: 30000 }); - - await page.reload(); - await expect(page.locator(NAV.zonas)).toHaveText('Monitored areas', { timeout: 30000 }); - }); -}); diff --git a/e2e/tests/zone-union.spec.js b/e2e/tests/zone-union.spec.js deleted file mode 100644 index 4eb5cbf..0000000 --- a/e2e/tests/zone-union.spec.js +++ /dev/null @@ -1,54 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { signUp, addZoneThroughTheMap, waitForMap } from '../support/app.js'; - -// This is the flow that froze staging on 2026-07-30: subscribing to a zone makes -// the server recompute the union of every subscription, and while that ran on the -// main thread DDP stopped answering and every browser hung. A green run here means -// the page came back, the union reached the client, and the map drew it. -test.describe('suscripción a una zona y unión de zonas', () => { - test('creating a zone lists it and paints the union on both maps', async ({ page }) => { - await signUp(page); - await expect(page.getByText('No estás suscrito a fuegos en ninguna zona')).toBeVisible(); - - await addZoneThroughTheMap(page); - - // Back on the zones page, the "you have no zones" warning is gone. - await expect(page.getByText('No estás suscrito a fuegos en ninguna zona')).toHaveCount(0); - - // The union is a GeoJSON layer: leaflet draws it as an SVG path in the - // overlay pane. Nothing is drawn there until the server has recomputed and - // published it, so this also pins that the recompute finishes at all. - await waitForMap(page); - await expect(page.locator('.leaflet-overlay-pane path').first()).toBeVisible({ timeout: 60000 }); - - await page.goto('/zones'); - await waitForMap(page); - await expect(page.locator('.leaflet-overlay-pane path').first()).toBeVisible({ timeout: 60000 }); - }); - - test('the site keeps answering while the union is recomputed', async ({ page }) => { - await signUp(page); - await addZoneThroughTheMap(page); - - // A DDP round trip right after the insert: if the union blocked the event - // loop again, this navigation would hang instead of rendering. - await page.goto('/fires'); - await waitForMap(page); - await expect(page.locator('.leaflet-container').first()).toBeVisible(); - }); - - // Removing is the other half of the same server-side path: it schedules a full - // recompute instead of an incremental merge. - test('a zone can be removed again from the map', async ({ page }) => { - await signUp(page); - await addZoneThroughTheMap(page); - await expect(page.getByText('En verde, áreas de las que recibirás alertas de fuegos')).toBeVisible(); - - await page.getByRole('button', { name: /Editar/i }).click(); - await page.locator('.leaflet-marker-icon').first().click(); - await expect(page.getByText('¿Estás seguro/a?')).toBeVisible(); - await page.getByRole('button', { name: 'Sí', exact: true }).click(); - - await expect(page.getByText('No estás suscrito a fuegos en ninguna zona')).toBeVisible({ timeout: 60000 }); - }); -}); diff --git a/imports/api/ActiveFires/server/countFires.js b/imports/api/ActiveFires/server/countFires.js index 116ae5b..b3b8511 100644 --- a/imports/api/ActiveFires/server/countFires.js +++ b/imports/api/ActiveFires/server/countFires.js @@ -29,41 +29,40 @@ const findFiresInRegion = (zone) => { return result; }; -export const countRealFires = async (firesCursor) => { +export const countRealFires = (firesCursor) => { const realFires = []; - const fires = Array.isArray(firesCursor) ? firesCursor : await firesCursor.fetchAsync(); - for (const fire of fires) { + firesCursor.forEach((fire) => { if (debug) console.log(`${JSON.stringify(fire)} -----`); - const union = await firesUnion([fire]); + const union = firesUnion([fire]); if (debug) console.log(`${JSON.stringify(union)} -----`); const falsePos = whichAreFalsePositives(FalsePositives, union); const industries = whichAreFalsePositives(Industries, union); - if (await falsePos.countAsync() === 0 && await industries.countAsync() === 0) { + if (falsePos.count() === 0 && industries.count() === 0) { realFires.push(fire); } - } + }); // group fires - const realUnion = await firesUnion(realFires); + const realUnion = firesUnion(realFires); const unionCount = realUnion[0] && realUnion[0].geometry && realUnion[0].geometry.coordinates ? realUnion[0].geometry.coordinates.length : 0; return unionCount; }; -const countFiresInRegions = async (regions, stringsToRemove) => { +const countFiresInRegions = (regions, stringsToRemove) => { let total = 0; const fireStats = {}; - for (const region of regions.features) { + regions.features.forEach((region) => { const regionName = cleanProv(region.properties.name, stringsToRemove); const regionCode = region.properties.iso_a2; if (debug) console.log(`${regionName} -----`); try { const fires = findFiresInRegion(region); // TODO Also check neighbour fires (when better implementation of that part) - const initialCount = await fires.countAsync(); + const initialCount = fires.count(); if (initialCount > 0) { - const unionCount = await countRealFires(fires); + const unionCount = countRealFires(fires); if (debug) console.log(`${regionName} initial: ${initialCount}, union calc: ${unionCount}`); if (unionCount > 0) { total += unionCount; @@ -73,7 +72,7 @@ const countFiresInRegions = async (regions, stringsToRemove) => { } catch (e) { ravenLogger.log(e); } - } + }); return { total, fires: fireStats }; }; diff --git a/imports/api/ActiveFires/server/publications.js b/imports/api/ActiveFires/server/publications.js index 66c9991..03878b3 100644 --- a/imports/api/ActiveFires/server/publications.js +++ b/imports/api/ActiveFires/server/publications.js @@ -16,7 +16,7 @@ Meteor.publish('activefirestotal', function total() { return counter; }); -const activefires = async (northEastLng, northEastLat, southWestLng, southWestLat, withMarks) => { +const activefires = (northEastLng, northEastLat, southWestLng, southWestLat, withMarks) => { const fires = ActiveFires.find({ ourid: { $geoWithin: { @@ -36,10 +36,10 @@ const activefires = async (northEastLng, northEastLat, southWestLng, southWestLa } }); - if (Meteor.isDevelopment) console.log(`Active fires total: ${await fires.countAsync()}`); + if (Meteor.isDevelopment) console.log(`Active fires total: ${fires.count()}`); - if (withMarks && (await fires.fetchAsync()).length > 0) { - const union = await firesUnion(fires); + if (withMarks && fires.fetch().length > 0) { + const union = firesUnion(fires); const falsePos = whichAreFalsePositives(FalsePositives, union); const industries = whichAreFalsePositives(Industries, union); return [fires, falsePos, industries]; @@ -48,7 +48,7 @@ const activefires = async (northEastLng, northEastLat, southWestLng, southWestLa return fires; }; -Meteor.publish('activefiresmyloc', async function activeInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat, withMarks) { +Meteor.publish('activefiresmyloc', function activeInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat, withMarks) { // latitude -90 and 90 and the longitude between -180 and 180 check(northEastLng, NumberBetween(-180, 180)); check(southWestLat, NumberBetween(-90, 90)); diff --git a/imports/api/ActiveFiresUnion/server/publications.js b/imports/api/ActiveFiresUnion/server/publications.js index f3d8db7..6a126d8 100644 --- a/imports/api/ActiveFiresUnion/server/publications.js +++ b/imports/api/ActiveFiresUnion/server/publications.js @@ -16,7 +16,7 @@ Meteor.publish('activefiresuniontotal', function total() { return counter; }); -const activeFiresUnion = async (b1, b2, c1, c2, withMarks) => { +const activeFiresUnion = (b1, b2, c1, c2, withMarks) => { // a --- b // c --- d const geometry = { @@ -46,7 +46,7 @@ const activeFiresUnion = async (b1, b2, c1, c2, withMarks) => { } }); - if (Meteor.isDevelopment) console.log(`Active fires union total: ${await fires.countAsync()}`); + if (Meteor.isDevelopment) console.log(`Active fires union total: ${fires.count()}`); /* if (withMarks && fires.fetch().length > 0) { @@ -59,7 +59,7 @@ const activeFiresUnion = async (b1, b2, c1, c2, withMarks) => { return fires; }; -Meteor.publish('activefiresunionmyloc', async function activeInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat, withMarks) { +Meteor.publish('activefiresunionmyloc', function activeInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat, withMarks) { // latitude -90 and 90 and the longitude between -180 and 180 check(northEastLng, NumberBetween(-180, 180)); check(southWestLat, NumberBetween(-90, 90)); diff --git a/imports/api/Comments/Comments.js b/imports/api/Comments/Comments.js deleted file mode 100644 index a45a80f..0000000 --- a/imports/api/Comments/Comments.js +++ /dev/null @@ -1,29 +0,0 @@ -/* eslint-disable import/no-absolute-path */ -import { Mongo } from 'meteor/mongo'; - -/* - * Comments collection (fire-page comments). - * - * Replaces the abandoned `arkham:comments-ui` Atmosphere package (no Meteor 2.x - * build) with a small React + methods implementation. The collection name - * ('comments') and the core document shape are kept so existing production - * documents keep rendering: - * - * { referenceId: 'fire-', content, userId, username, - * media: { type, content }, likes: [userId], dislikes: [userId], - * status: 'approved', createdAt, updatedAt } - * - * Legacy documents may also carry replies[]/starRatings[]/ratingScore/ - * isAnonymous — those features were disabled in this app's config and are - * ignored by the new UI. - */ -const Comments = new Mongo.Collection('comments'); - -// Writes only through the vetted server methods. -Comments.deny({ - insert: () => true, - update: () => true, - remove: () => true -}); - -export default Comments; diff --git a/imports/api/Comments/mediaAnalyzers.js b/imports/api/Comments/mediaAnalyzers.js deleted file mode 100644 index 7b7a10b..0000000 --- a/imports/api/Comments/mediaAnalyzers.js +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Media analyzers ported from the old arkham:comments-ui package - * (lib/services/media-analyzers). Given a comment's text, detect an embeddable - * image or YouTube URL and return { type, content }. First match wins. - */ - -const imageAnalyzer = { - name: 'image', - getMediaFromContent(content) { - if (content) { - const urls = content.match(/(\S+\.[^/\s]+(\/\S+|\/|))(.jpg|.png|.gif)/g); - if (urls && urls[0]) return urls[0]; - } - return ''; - } -}; - -const youtubeAnalyzer = { - name: 'youtube', - getMediaFromContent(content) { - const parts = (content || '').match(/(?:https?:\/\/)?(?:www\.youtube\.com|youtu\.?be)\/([\w=?]+)/); - let mediaContent = ''; - if (parts && parts[1]) { - let id = parts[1]; - if (id.indexOf('v=') > -1) { - const subParts = id.match(/v=([\w]+)/); - if (subParts && subParts[1]) id = subParts[1]; - } - mediaContent = `https://www.youtube.com/embed/${id}`; - } - return mediaContent; - } -}; - -const analyzers = [imageAnalyzer, youtubeAnalyzer]; - -// Returns { type, content } or {}. -export default function getMediaFromContent(content) { - for (let i = 0; i < analyzers.length; i += 1) { - const mediaContent = analyzers[i].getMediaFromContent(content); - if (mediaContent) return { type: analyzers[i].name, content: mediaContent }; - } - return {}; -} diff --git a/imports/api/Comments/server/index.js b/imports/api/Comments/server/index.js deleted file mode 100644 index 40b4368..0000000 --- a/imports/api/Comments/server/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// Server-side registration for the Comments feature. -import './methods'; -import './publications'; diff --git a/imports/api/Comments/server/methods.js b/imports/api/Comments/server/methods.js deleted file mode 100644 index fd491cd..0000000 --- a/imports/api/Comments/server/methods.js +++ /dev/null @@ -1,102 +0,0 @@ -/* eslint-disable import/no-absolute-path */ -import { Meteor } from 'meteor/meteor'; -import { check } from 'meteor/check'; -import Comments from '/imports/api/Comments/Comments'; -import getMediaFromContent from '/imports/api/Comments/mediaAnalyzers'; -import rateLimit from '/imports/modules/rate-limit'; -import onCommentAdd from '/imports/api/Comments/server/onCommentAdd'; - -const MAX_LEN = 5000; - -function usernameOf(user) { - return (user && user.profile && user.profile.name && user.profile.name.first) - ? user.profile.name.first - : ''; -} - -async function requireOwner(commentId, userId) { - const comment = await Comments.findOneAsync(commentId); - if (!comment) throw new Meteor.Error('404', 'Comment not found'); - if (comment.userId !== userId) throw new Meteor.Error('403', 'Not your comment'); - return comment; -} - -async function toggle(commentId, field, otherField, userId) { - check(commentId, String); - if (!userId) throw new Meteor.Error('403', 'Login required'); - const comment = await Comments.findOneAsync(commentId); - if (!comment) throw new Meteor.Error('404', 'Comment not found'); - const has = (comment[field] || []).includes(userId); - const modifier = has - ? { $pull: { [field]: userId } } - : { $addToSet: { [field]: userId }, $pull: { [otherField]: userId } }; - await Comments.updateAsync(commentId, modifier); -} - -Meteor.methods({ - 'comments.insert': async function commentsInsert(referenceId, content) { - check(referenceId, String); - check(content, String); - if (!this.userId) throw new Meteor.Error('403', 'Login required to comment'); - const text = content.trim(); - if (!text) throw new Meteor.Error('400', 'Empty comment'); - if (text.length > MAX_LEN) throw new Meteor.Error('400', 'Comment too long'); - - const now = new Date(); - const doc = { - referenceId, - content: text, - userId: this.userId, - username: usernameOf(await Meteor.users.findOneAsync(this.userId)), - media: getMediaFromContent(text), - likes: [], - dislikes: [], - status: 'approved', - createdAt: now, - updatedAt: now - }; - const _id = await Comments.insertAsync(doc); - // Fire-and-forget: notify other commenters of this fire. Never let a mail - // failure break the insert. - try { - onCommentAdd({ ...doc, _id }); - } catch (e) { - console.warn(`comments onCommentAdd failed: ${e}`); - } - return _id; - }, - - 'comments.edit': async function commentsEdit(commentId, content) { - check(commentId, String); - check(content, String); - if (!this.userId) throw new Meteor.Error('403', 'Login required'); - await requireOwner(commentId, this.userId); - const text = content.trim(); - if (!text) throw new Meteor.Error('400', 'Empty comment'); - if (text.length > MAX_LEN) throw new Meteor.Error('400', 'Comment too long'); - await Comments.updateAsync(commentId, { - $set: { content: text, media: getMediaFromContent(text), updatedAt: new Date() } - }); - }, - - 'comments.remove': async function commentsRemove(commentId) { - check(commentId, String); - if (!this.userId) throw new Meteor.Error('403', 'Login required'); - await requireOwner(commentId, this.userId); - await Comments.removeAsync(commentId); - }, - - 'comments.like': async function commentsLike(commentId) { - await toggle(commentId, 'likes', 'dislikes', this.userId); - }, - - 'comments.dislike': async function commentsDislike(commentId) { - await toggle(commentId, 'dislikes', 'likes', this.userId); - } -}); - -rateLimit({ - methods: ['comments.insert', 'comments.edit', 'comments.remove', 'comments.like', 'comments.dislike'], - limit: 5, - timeRange: 1000 -}); diff --git a/imports/api/Comments/server/onCommentAdd.js b/imports/api/Comments/server/onCommentAdd.js deleted file mode 100644 index d747707..0000000 --- a/imports/api/Comments/server/onCommentAdd.js +++ /dev/null @@ -1,42 +0,0 @@ -/* eslint-disable import/no-absolute-path */ -import { Meteor } from 'meteor/meteor'; -import i18n from 'i18next'; -import sendEmail, { subjectTruncate } from '/imports/modules/server/send-email'; -import getEmailOf from '/imports/modules/get-email-of-user'; -import Comments from '/imports/api/Comments/Comments'; - -/* - * When a comment is added to a fire, email the other users who commented on the - * same fire (uniq, excluding the author). Ported from the old - * startup/server/comments.js `onEvent` handler. - */ -export default function onCommentAdd(payload) { - const { referenceId, userId } = payload; - const query = { referenceId, userId: { $ne: userId } }; - - Comments.rawCollection().distinct('userId', query).then(async (users) => { - const path = referenceId.replace(/fire-/, 'fire/archive/'); - const fireUrl = Meteor.absoluteUrl(path); - await Meteor.users.find({ _id: { $in: users } }).forEachAsync((user) => { - const { firstName, emailAddress } = getEmailOf(user); - if (emailAddress) { - const emailOpts = { - to: emailAddress, - subject: subjectTruncate.apply(i18n.t('Hay más información sobre un fuego')), - lang: user.lang, - template: 'new-fire-comment', - templateVars: { - applicationName: i18n.t('AppName'), - firstName, - fireUrl - } - }; - sendEmail(emailOpts).catch((error) => { - console.warn(`comments new-fire-comment mail failed: ${error}`); - }); - } - }); - }).catch((error) => { - console.warn(`comments distinct() failed: ${error}`); - }); -} diff --git a/imports/api/Comments/server/publications.js b/imports/api/Comments/server/publications.js deleted file mode 100644 index dbd2b0b..0000000 --- a/imports/api/Comments/server/publications.js +++ /dev/null @@ -1,14 +0,0 @@ -/* eslint-disable import/no-absolute-path */ -/* eslint-disable prefer-arrow-callback */ -import { Meteor } from 'meteor/meteor'; -import { check } from 'meteor/check'; -import Comments from '/imports/api/Comments/Comments'; - -// Comments for one reference (a fire page), oldest first. -Meteor.publish('comments.forReference', function commentsForReference(referenceId) { - check(referenceId, String); - return Comments.find( - { referenceId }, - { sort: { createdAt: 1 } } - ); -}); diff --git a/imports/api/Common/id.js b/imports/api/Common/id.js deleted file mode 100644 index d419982..0000000 --- a/imports/api/Common/id.js +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Fire/collection docs use `idGeneration: 'MONGO'`, so their `_id` is a - * `Mongo.ObjectID`, whose `.toString()` yields `ObjectID("")` — NOT the - * bare hex. Interpolating an `_id` straight into a URL or any string therefore - * produces `.../fire/archive/ObjectID("...")`, which the routes can't match. - * - * Use `hexId()` wherever an id becomes part of a string (URLs, comment - * reference ids, DOM ids). It returns the 24-char hex for an ObjectID and - * passes plain-string ids through unchanged. Do NOT use it for DDP method - * args whose `check()` expects `Meteor.Collection.ObjectID` — pass the object. - */ -export const hexId = id => - (id && typeof id.toHexString === 'function' ? id.toHexString() : id); - -export default hexId; diff --git a/imports/api/FalsePositives/methods.js b/imports/api/FalsePositives/methods.js index 2caf2b9..85005ba 100644 --- a/imports/api/FalsePositives/methods.js +++ b/imports/api/FalsePositives/methods.js @@ -6,7 +6,7 @@ import FalsePositiveTypes from './FalsePositiveTypes'; import Fires from '../Fires/Fires'; import rateLimit from '../../modules/rate-limit'; -export async function upsertFalsePositive(keyType, owner, fire) { +export function upsertFalsePositive(keyType, owner, fire) { const fireType = fire.type; if (fireType === 'vecinal') { throw new Meteor.Error('500', 'No se puede marcar este tipo de fuego'); @@ -22,7 +22,7 @@ export async function upsertFalsePositive(keyType, owner, fire) { fireId: fire._id, geo: fire.ourid }; - return await FalsePositives.upsertAsync({ geo: fire.ourid, owner }, { $set: set }, { multi: false, upsert: true }); + return FalsePositives.upsert({ geo: fire.ourid, owner }, { $set: set }, { multi: false, upsert: true }); } catch (exception) { console.log(exception); throw new Meteor.Error('500', exception); @@ -30,7 +30,7 @@ export async function upsertFalsePositive(keyType, owner, fire) { } Meteor.methods({ - 'falsePositives.insert': async function falsePositivesInsert(fireId, keyType) { + 'falsePositives.insert': function falsePositivesInsert(fireId, keyType) { check(fireId, Meteor.Collection.ObjectID); check(keyType, String); const type = FalsePositiveTypes[keyType]; @@ -39,10 +39,10 @@ Meteor.methods({ throw new Meteor.Error('500', 'Regístrate o inicia sesión para aportar información sobre este fuego'); } check(this.userId, String); - const fire = await Fires.findOneAsync(fireId); + const fire = Fires.findOne(fireId); const owner = this.userId; if (fire) { - await upsertFalsePositive(keyType, owner, fire); + upsertFalsePositive(keyType, owner, fire); } else { throw new Meteor.Error('500', 'Fuego no encontrado'); } diff --git a/imports/api/FalsePositives/server/publications.js b/imports/api/FalsePositives/server/publications.js index df6377d..a79ae1a 100644 --- a/imports/api/FalsePositives/server/publications.js +++ b/imports/api/FalsePositives/server/publications.js @@ -17,8 +17,8 @@ Meteor.publish('falsePositivesTotal', function total() { return counter; }); -export const firesUnion = async (fires) => { - const firesArray = Array.isArray(fires) ? fires : await fires.fetchAsync(); // if not is a cursor +export const firesUnion = (fires) => { + const firesArray = Array.isArray(fires) ? fires : fires.fetch(); // if not is a cursor const remap = firesArray.map(function remap(doc) { const isNASA = doc.type === 'modis' || doc.type === 'viirs'; const pixelSize = doc.type === 'viirs' ? 0.375 : 1; // viirs has 375m pixel size, modis 1000m @@ -84,7 +84,7 @@ const find = (collection, northEastLng, northEastLat, southWestLng, southWestLat return fires; }; -Meteor.publish('falsePositivesMyloc', function falsePositivesInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat) { +Meteor.publishTransformed('falsePositivesMyloc', function falsePositivesInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat) { // latitude -90 and 90 and the longitude between -180 and 180 check(northEastLng, NumberBetween(-180, 180)); check(southWestLat, NumberBetween(-90, 90)); @@ -94,7 +94,7 @@ Meteor.publish('falsePositivesMyloc', function falsePositivesInMyLoc(northEastLn return find(FalsePositives, northEastLng, northEastLat, southWestLng, southWestLat); }); -Meteor.publish('industriesMyloc', function industriesInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat) { +Meteor.publishTransformed('industriesMyloc', function industriesInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat) { // latitude -90 and 90 and the longitude between -180 and 180 check(northEastLng, NumberBetween(-180, 180)); check(southWestLat, NumberBetween(-90, 90)); diff --git a/imports/api/FireAlerts/server/publications.js b/imports/api/FireAlerts/server/publications.js index a3d5350..6642f35 100644 --- a/imports/api/FireAlerts/server/publications.js +++ b/imports/api/FireAlerts/server/publications.js @@ -6,7 +6,7 @@ import { check } from 'meteor/check'; import { NumberBetween } from '/imports/modules/server/other-checks'; import FireAlerts from '../FireAlerts'; -Meteor.publish('fireAlerts', async function fireAlerts(northEastLng, northEastLat, southWestLng, southWestLat) { +Meteor.publish('fireAlerts', function fireAlerts(northEastLng, northEastLat, southWestLng, southWestLat) { // latitude -90 and 90 and the longitude between -180 and 180 check(northEastLng, NumberBetween(-180, 180)); check(southWestLat, NumberBetween(-90, 90)); @@ -37,6 +37,6 @@ Meteor.publish('fireAlerts', async function fireAlerts(northEastLng, northEastLa track: 1 } }); - if (Meteor.isDevelopment) console.log(`Neighbour alerts total: ${await fires.countAsync()}`); + if (Meteor.isDevelopment) console.log(`Neighbour alerts total: ${fires.count()}`); return fires; }); diff --git a/imports/api/Fires/server/publications.js b/imports/api/Fires/server/publications.js index 2f56979..3862beb 100644 --- a/imports/api/Fires/server/publications.js +++ b/imports/api/Fires/server/publications.js @@ -53,12 +53,12 @@ const fixConfidence = (obj) => { return obj; }; -const findOrCreateFire = async (obj) => { +const findOrCreateFire = (obj) => { const fire = findFire(obj); - // console.log(`Found: ${await fire.countAsync()}`); + // console.log(`Found: ${fire.count()}`); if (!obj.address) { try { - const rev = await geocoder.reverse({ lat: obj.lat, lon: obj.lon }); + const rev = Promise.await(geocoder.reverse({ lat: obj.lat, lon: obj.lon })); if (rev[0]) { obj.address = rev[0].formattedAddress; } @@ -67,23 +67,23 @@ const findOrCreateFire = async (obj) => { console.warn(reve); } } - if (await fire.countAsync() === 0) { + if (fire.count() === 0) { // const result = // console.log('Creating new fire'); - await FiresCollection.upsertAsync({ ourid: obj.ourid, when: obj.when, type: obj.type }, { $set: obj }, { multi: false, upsert: true }); + FiresCollection.upsert({ ourid: obj.ourid, when: obj.when, type: obj.type }, { $set: obj }, { multi: false, upsert: true }); // console.log(JSON.stringify(result)); } return findFire(obj); }; -Meteor.publish('fireFromAlertId', async function fireFromAlertId(_id) { +Meteor.publish('fireFromAlertId', function fireFromAlertId(_id) { try { check(_id, String); // console.log(`Looking for alert fire ${_id}`); - const fire = await FireAlertsCollection.findOneAsync(new Meteor.Collection.ObjectID(_id)); + const fire = FireAlertsCollection.findOne(new Meteor.Collection.ObjectID(_id)); if (fire) { // console.info(`Active fire found: ${_id}`); - return await findOrCreateFire(fixConfidence(fire)); + return findOrCreateFire(fixConfidence(fire)); } console.info(`Alert fire not found: ${_id}`); // Not found in active fires! @@ -94,14 +94,14 @@ Meteor.publish('fireFromAlertId', async function fireFromAlertId(_id) { } }); -Meteor.publish('fireFromActiveId', async function fireFromActiveId(_id) { +Meteor.publish('fireFromActiveId', function fireFromActiveId(_id) { try { check(_id, String); // console.log(`Looking for active fire ${_id}`); - const fire = await ActiveFiresCollection.findOneAsync(new Meteor.Collection.ObjectID(_id)); + const fire = ActiveFiresCollection.findOne(new Meteor.Collection.ObjectID(_id)); if (fire) { // console.info(`Active fire found: ${_id}`); - return await findOrCreateFire(fixConfidence(fire)); + return findOrCreateFire(fixConfidence(fire)); } console.info(`Active fire not found: ${_id}`); // Not found in active fires! @@ -112,14 +112,14 @@ Meteor.publish('fireFromActiveId', async function fireFromActiveId(_id) { } }); -Meteor.publish('fireFromId', async function fireFromId(_id) { +Meteor.publish('fireFromId', function fireFromId(_id) { try { check(_id, String); // console.log(`Looking for archive fire ${_id}`); const fire = FiresCollection.find(new Meteor.Collection.ObjectID(_id)); - if (await fire.countAsync() !== 0) { + if (fire.count() !== 0) { // console.info(`Archive fire found: ${_id}`); - const union = await firesUnion(fire); + const union = firesUnion(fire); const falsePos = whichAreFalsePositives(FalsePositives, union); const industries = whichAreFalsePositives(Industries, union); return [fire, falsePos, industries]; @@ -139,12 +139,13 @@ function logUrl(fireEnc, params) { ravenLogger.log(message); } -export async function fireFromHash(fireEnc, params) { +export function fireFromHash(fireEnc, params) { check(fireEnc, String); check(params, Object); // console.log(fireEnc); - const unsealed = await unsealW(fireEnc); + // const unsealed = Promise.await(urlEnc.decrypt(fireEnc)); + const unsealed = Promise.await(unsealW(fireEnc)); if (unsealed === undefined) { throw Error(`Fail to unseal '${fireEnc}'`); } @@ -158,16 +159,16 @@ export async function fireFromHash(fireEnc, params) { // FIXME: const unsealedFix = fixConfidence(unsealed); FiresCollection.schema.validate(unsealedFix); - return await findOrCreateFire(unsealedFix); + return findOrCreateFire(unsealedFix); /* console.log(`fires: ${fire.count()}`); * return fire; */ } -Meteor.publish('fireFromHash', async function fireFromHashFunc(fireEnc, params) { +Meteor.publish('fireFromHash', function fireFromHashFunc(fireEnc, params) { check(fireEnc, String); check(params, Object); try { - return await fireFromHash(fireEnc, params); + return fireFromHash(fireEnc, params); } catch (e) { console.warn(e); logUrl(fireEnc, params); diff --git a/imports/api/OAuth/server/methods.js b/imports/api/OAuth/server/methods.js index 3f1df74..6696da4 100644 --- a/imports/api/OAuth/server/methods.js +++ b/imports/api/OAuth/server/methods.js @@ -4,16 +4,16 @@ import { ServiceConfiguration } from 'meteor/service-configuration'; import rateLimit from '../../../modules/rate-limit'; Meteor.methods({ - 'oauth.verifyConfiguration': async function oauthVerifyConfiguration(services) { + 'oauth.verifyConfiguration': function oauthVerifyConfiguration(services) { check(services, Array); try { const verifiedServices = []; - for (const service of services) { - if (await ServiceConfiguration.configurations.findOneAsync({ service })) { + services.forEach((service) => { + if (ServiceConfiguration.configurations.findOne({ service })) { verifiedServices.push(service); } - } + }); return verifiedServices.sort(); } catch (exception) { throw new Meteor.Error('500', exception); diff --git a/imports/api/Rest/Rest.js b/imports/api/Rest/Rest.js index d7eb83f..ae5e383 100644 --- a/imports/api/Rest/Rest.js +++ b/imports/api/Rest/Rest.js @@ -89,8 +89,8 @@ if (!Meteor.settings.private.internalApiToken) { }, endpoints: { get: { - action: async function getFire() { - return Fires.findOneAsync(new Meteor.Collection.ObjectID(this.urlParams.id)); + action: function getFire() { + return Fires.findOne(new Meteor.Collection.ObjectID(this.urlParams.id)); } } } @@ -98,22 +98,22 @@ if (!Meteor.settings.private.internalApiToken) { // Maps to: /api/v1/status/last-fire-check apiV1.addRoute('status/last-fire-check', { authRequired: false }, { - get: async function get() { - return SiteSettings.findOneAsync({ name: 'last-fire-check' }); + get: function get() { + return SiteSettings.findOne({ name: 'last-fire-check' }); } }); // Maps to: /api/v1/status/last-fire-detected apiV1.addRoute('status/last-fire-detected', { authRequired: false }, { - get: async function get() { - return ActiveFiresCollection.findOneAsync({}, { sort: { when: -1 } }); + get: function get() { + return ActiveFiresCollection.findOne({}, { sort: { when: -1 } }); } }); // Maps to: /api/v1/status/active-fires-count apiV1.addRoute('status/active-fires-count', { authRequired: false }, { - get: async function get() { - return { total: await ActiveFiresCollection.find({}).countAsync() }; + get: function get() { + return { total: ActiveFiresCollection.find({}).count() }; } }); @@ -124,7 +124,7 @@ if (!Meteor.settings.private.internalApiToken) { } }); - async function getFires(route, full) { + function getFires(route, full) { const lat = Number(route.urlParams.lat); const lng = Number(route.urlParams.lng); const km = Number(route.urlParams.km); @@ -163,11 +163,7 @@ if (!Meteor.settings.private.internalApiToken) { } }); - // Meteor 3 / mongodb driver 6: countAsync() on a $near cursor fails - // ($near/$geoNear not allowed inside the aggregation countDocuments uses). - // Fetch once and derive the total from the array length instead. - const firesA = await fires.fetchAsync(); - const result = { total: firesA.length, real: await countRealFires(firesA) }; + const result = { total: fires.count(), real: countRealFires(fires) }; if (debug) console.log(`Query for fires in ${lat}, ${lng} in ${km} km radius ${result}`); if (!full) { @@ -176,16 +172,19 @@ if (!Meteor.settings.private.internalApiToken) { let union; + // TODO only get real + const firesA = fires.fetch(); + if (firesA.length > 0) { - union = await firesUnion(firesA); + union = firesUnion(fires); } else { union = zoneToUnion(lat, lng, km); } const falsePos = whichAreFalsePositives(FalsePositives, union); const industries = whichAreFalsePositives(Industries, union); result.fires = firesA; - result.industries = await industries.fetchAsync(); - result.falsePos = await falsePos.fetchAsync(); + result.industries = industries.fetch(); + result.falsePos = falsePos.fetch(); return result; } @@ -193,13 +192,13 @@ if (!Meteor.settings.private.internalApiToken) { // 100 km max // Ex: http://127.0.0.1:3000/api/v1/fires-in/token/38.736946/-9.142685/100 apiV1.addRoute('fires-in/:token/:lat/:lng/:km', { authRequired: false }, { - get: async function get() { + get: function get() { return getFires(this, false); } }); apiV1.addRoute('fires-in-full/:token/:lat/:lng/:km', { authRequired: false }, { - get: async function get() { + get: function get() { return getFires(this, true); } }); @@ -210,7 +209,7 @@ if (!Meteor.settings.private.internalApiToken) { // // https://docs.meteor.com/api/passwords.html#Accounts-createUser apiV1.addRoute('mobile/users', { authRequired: false }, { - post: async function post() { + post: function post() { const { token, mobileToken, lang } = this.bodyParams; try { check(token, String); @@ -226,23 +225,22 @@ if (!Meteor.settings.private.internalApiToken) { let username; const already = Meteor.users.find({ fireBaseToken: mobileToken }); - const alreadyCount = await already.countAsync(); - if (alreadyCount > 1) { + if (already.count() > 1) { return restivusError(500, 'Unexpected error in REST call: several users with that mobile token?'); - } else if (alreadyCount === 1) { - username = (await already.fetchAsync())[0].username; + } else if (already.count() === 1) { + username = already.fetch()[0].username; } else { do { username = Random.id(15); - } while (await Meteor.users.find({ username }).countAsync() !== 0); + } while (Meteor.users.find({ username }).count() !== 0); } // FIXME check valid lang const now = new Date(); - const result = await Meteor.users.upsertAsync({ + const result = Meteor.users.upsert({ fireBaseToken: mobileToken }, { $set: { @@ -262,7 +260,7 @@ if (!Meteor.settings.private.internalApiToken) { console.log(this.queryParams); } - const upsertUser = await Meteor.users.findOneAsync({ username }); + const upsertUser = Meteor.users.findOne({ username }); if (debug) console.log(upsertUser); @@ -279,7 +277,7 @@ if (!Meteor.settings.private.internalApiToken) { // max sitance: 100km apiV1.addRoute('mobile/subscriptions', { authRequired: false }, { - post: async function post() { + post: function post() { const { token, mobileToken, @@ -307,7 +305,7 @@ if (!Meteor.settings.private.internalApiToken) { const failed = checkAuthToken(token); if (failed) return failed; - const user = await Meteor.users.findOneAsync({ fireBaseToken: mobileToken }); + const user = Meteor.users.findOne({ fireBaseToken: mobileToken }); if (!user) return failMsg('User not found'); const newSubs = {}; @@ -319,7 +317,7 @@ if (!Meteor.settings.private.internalApiToken) { let result; try { - result = await subscriptionsInsert(newSubs, user._id, 'mobile'); + result = subscriptionsInsert(newSubs, user._id, 'mobile'); } catch (e) { return fail(e); } @@ -327,57 +325,8 @@ if (!Meteor.settings.private.internalApiToken) { } }); - // NOTE: json-routes 3.0 (Meteor 3) matches routes in registration order, so - // the literal `all/:token/:mobileToken` route MUST be registered before the - // `:token/:mobileToken/:subsId` route — otherwise a GET to - // `.../all//` matches the 3-param route (delete-only) and restivus - // replies "API endpoint does not exist". - apiV1.addRoute('mobile/subscriptions/all/:token/:mobileToken', { authRequired: false }, { - get: async function get() { - const { token, mobileToken } = this.urlParams; - try { - check(token, String); - check(mobileToken, String); - } catch (e) { - return defaultFailParams(e); - } - - const failed = checkAuthToken(token); - if (failed) return failed; - - const user = await Meteor.users.findOneAsync({ fireBaseToken: mobileToken }); - if (!user) return failMsg('User not found'); - - const result = Subscriptions.find({ owner: user._id }); - - return jsend.success({ subscriptions: await result.fetchAsync(), count: await result.countAsync() }); - }, - delete: async function delAll() { - const { token, mobileToken } = this.urlParams; - try { - check(token, String); - check(mobileToken, String); - } catch (e) { - return defaultFailParams(e); - } - - const failed = checkAuthToken(token); - if (failed) return failed('Auth api check failed'); - - if (await Meteor.users.find({ fireBaseToken: mobileToken }).countAsync() !== 1) return fail; - - const user = await Meteor.users.findOneAsync({ fireBaseToken: mobileToken }); - if (!user) return failMsg('User not found'); - - const toRemove = await Subscriptions.find({ owner: user._id }).countAsync(); - await Subscriptions.removeAsync({ owner: user._id }); - - return jsend.success({ count: toRemove }); - } - }); - apiV1.addRoute('mobile/subscriptions/:token/:mobileToken/:subsId', { authRequired: false }, { - delete: async function del() { + delete: function del() { const { token, mobileToken, @@ -394,11 +343,11 @@ if (!Meteor.settings.private.internalApiToken) { const failed = checkAuthToken(token); if (failed) return failed; - const user = await Meteor.users.findOneAsync({ fireBaseToken: mobileToken }); + const user = Meteor.users.findOne({ fireBaseToken: mobileToken }); if (!user) return failMsg('User not found'); try { - await Subscriptions.removeAsync({ owner: user._id, _id: new Meteor.Collection.ObjectID(subsId) }); + Subscriptions.remove({ owner: user._id, _id: new Meteor.Collection.ObjectID(subsId) }); } catch (e) { return fail(e); } @@ -407,8 +356,52 @@ if (!Meteor.settings.private.internalApiToken) { } }); + apiV1.addRoute('mobile/subscriptions/all/:token/:mobileToken', { authRequired: false }, { + get: function get() { + const { token, mobileToken } = this.urlParams; + try { + check(token, String); + check(mobileToken, String); + } catch (e) { + return defaultFailParams(e); + } + + const failed = checkAuthToken(token); + if (failed) return failed; + + const user = Meteor.users.findOne({ fireBaseToken: mobileToken }); + if (!user) return failMsg('User not found'); + + const result = Subscriptions.find({ owner: user._id }); + + return jsend.success({ subscriptions: result.fetch(), count: result.count() }); + }, + delete: function delAll() { + const { token, mobileToken } = this.urlParams; + try { + check(token, String); + check(mobileToken, String); + } catch (e) { + return defaultFailParams(e); + } + + const failed = checkAuthToken(token); + if (failed) return failed('Auth api check failed'); + + if (Meteor.users.find({ fireBaseToken: mobileToken }).count() !== 1) return fail; + + const user = Meteor.users.findOne({ fireBaseToken: mobileToken }); + if (!user) return failMsg('User not found'); + + const toRemove = Subscriptions.find({ owner: user._id }).count(); + Subscriptions.remove({ owner: user._id }); + + return jsend.success({ count: toRemove }); + } + }); + apiV1.addRoute('status/subs-public-union/:token', { authRequired: false }, { - get: async function get() { + get: function get() { const { token } = this.urlParams; try { check(token, String); @@ -419,15 +412,15 @@ if (!Meteor.settings.private.internalApiToken) { const failed = checkAuthToken(token); if (failed) return failed; - const currentUnion = await SiteSettings.findOneAsync({ name: 'subs-public-union' }); - const userSubsBounds = await SiteSettings.findOneAsync({ name: 'subs-public-union-bounds' }); + const currentUnion = SiteSettings.findOne({ name: 'subs-public-union' }); + const userSubsBounds = SiteSettings.findOne({ name: 'subs-public-union-bounds' }); return jsend.success({ union: currentUnion, bounds: userSubsBounds }); } }); apiV1.addRoute('mobile/falsepositive', { authRequired: false }, { - post: async function post() { + post: function post() { const { token, mobileToken, @@ -447,15 +440,15 @@ if (!Meteor.settings.private.internalApiToken) { const failed = checkAuthToken(token); if (failed) return failed; - const user = await Meteor.users.findOneAsync({ fireBaseToken: mobileToken }); + const user = Meteor.users.findOne({ fireBaseToken: mobileToken }); if (!user) return failMsg('User not found'); console.log(`Marking hash fire as '${type}' false positive: ${sealed}`); - const fireC = await fireFromHash(sealed, { id: sealed }); + const fireC = fireFromHash(sealed, { id: sealed }); if (fireC && typeof fireC === 'object') { - const fire = (await fireC.fetchAsync())[0]; + const fire = fireC.fetch()[0]; console.log(`Marking fire as false positive: ${fire._id}`); - const result = await upsertFalsePositive(type, user._id, fire); + const result = upsertFalsePositive(type, user._id, fire); return jsend.success({ upsert: result }); } return failMsg('Cannot mark fire as false positive'); diff --git a/imports/api/Subscriptions/methods.js b/imports/api/Subscriptions/methods.js index 63eaa17..cf927e0 100644 --- a/imports/api/Subscriptions/methods.js +++ b/imports/api/Subscriptions/methods.js @@ -10,7 +10,7 @@ function geo(doc) { }; } -export async function subscriptionsInsert(doc, userId, type) { +export function subscriptionsInsert(doc, userId, type) { check(doc, { _id: Match.Maybe(Meteor.Collection.ObjectID), location: Match.ObjectIncluding({ lat: Number, lon: Number }), @@ -23,23 +23,23 @@ export async function subscriptionsInsert(doc, userId, type) { ...doc }; // console.log(newDoc); - const already = await Subscriptions.findOneAsync(newDoc); + const already = Subscriptions.findOne(newDoc); if (already) { throw new Meteor.Error('on-already-subscribed', 'The user is already subscribed to this area'); } try { - return await Subscriptions.insertAsync(newDoc); + return Subscriptions.insert(newDoc); } catch (exception) { // console.error(exception); throw new Meteor.Error('500', exception); } } -export async function subscriptionsRemove(subscriptionId) { +export function subscriptionsRemove(subscriptionId) { check(subscriptionId, Meteor.Collection.ObjectID); try { - return await Subscriptions.removeAsync(subscriptionId); + return Subscriptions.remove(subscriptionId); } catch (exception) { console.error(exception); throw new Meteor.Error('500', exception); @@ -54,7 +54,7 @@ Meteor.methods({ }); return subscriptionsInsert(doc, this.userId, 'web'); }, - 'subscriptions.update': async function subscriptionsUpdate(doc) { + 'subscriptions.update': function subscriptionsUpdate(doc) { check(doc, { _id: Meteor.Collection.ObjectID, location: Match.ObjectIncluding({ lat: Number, lon: Number }), @@ -65,7 +65,7 @@ Meteor.methods({ const dup = doc; const subscriptionId = doc._id; dup.geo = geo(doc); - await Subscriptions.updateAsync(subscriptionId, { $set: dup }); + Subscriptions.update(subscriptionId, { $set: dup }); return subscriptionId; // Return _id so we can redirect to subscription after update. } catch (exception) { throw new Meteor.Error('500', exception); diff --git a/imports/api/Users/server/edit-profile.js b/imports/api/Users/server/edit-profile.js index 2bdea94..fc44093 100644 --- a/imports/api/Users/server/edit-profile.js +++ b/imports/api/Users/server/edit-profile.js @@ -4,9 +4,9 @@ import { Meteor } from 'meteor/meteor'; let action; -const updateUser = async (userId, { emailAddress, profile }) => { +const updateUser = (userId, { emailAddress, profile }) => { try { - await Meteor.users.updateAsync(userId, { + Meteor.users.update(userId, { $set: { 'emails.0.address': emailAddress, profile, @@ -17,10 +17,10 @@ const updateUser = async (userId, { emailAddress, profile }) => { } }; -const editProfile = async ({ userId, profile }, promise) => { +const editProfile = ({ userId, profile }, promise) => { try { action = promise; - await updateUser(userId, profile); + updateUser(userId, profile); action.resolve(); } catch (exception) { action.reject(`[editProfile.handler] ${exception}`); diff --git a/imports/api/Users/server/methods.js b/imports/api/Users/server/methods.js index 62f8fbd..dc3c232 100644 --- a/imports/api/Users/server/methods.js +++ b/imports/api/Users/server/methods.js @@ -33,9 +33,9 @@ Meteor.methods({ throw new Meteor.Error('500', exception); }); }, - 'users.setLang': async function userLang(lang) { + 'users.setLang': function userLang(lang) { check(lang, validLangCode); - await Meteor.users.updateAsync({ _id: this.userId }, { $set: { lang } }); + Meteor.users.update({ _id: this.userId }, { $set: { lang } }); }, 'auth.getHash': async function getHash() { const token = Accounts._generateStampedLoginToken(); diff --git a/imports/modules/rate-limit.js b/imports/modules/rate-limit.js index dd88ce3..d98096f 100644 --- a/imports/modules/rate-limit.js +++ b/imports/modules/rate-limit.js @@ -9,15 +9,3 @@ export default ({ methods, limit, timeRange }) => { }, limit, timeRange); } }; - -// Same idea for subscriptions. DDPRateLimiter matches subscribe calls only when -// the rule pins `type: 'subscription'`, so this is a separate helper. -export const rateLimitSubscriptions = ({ subscriptions, limit, timeRange }) => { - if (Meteor.isServer) { - DDPRateLimiter.addRule({ - type: 'subscription', - name(name) { return subscriptions.indexOf(name) > -1; }, - connectionId() { return true; }, - }, limit, timeRange); - } -}; diff --git a/imports/modules/server/notificationsProcess.js b/imports/modules/server/notificationsProcess.js new file mode 100644 index 0000000..657aa2a --- /dev/null +++ b/imports/modules/server/notificationsProcess.js @@ -0,0 +1,153 @@ +/* eslint-disable import/no-absolute-path */ + +import { Meteor } from 'meteor/meteor'; +import Notifications from '/imports/api/Notifications/Notifications'; +import i18n from 'i18next'; +import moment from 'moment'; +import { dateLongFormat } from '/imports/api/Common/dates'; +// import sendMail from '/imports/startup/server/email'; +import sendEmail, { subjectTruncate } from '/imports/modules/server/send-email'; +import { isMailServerMaster } from '/imports/startup/server/email'; +// import { hr } from '/imports/startup/server/email'; +import getEmailOf from '/imports/modules/get-email-of-user'; +import image from 'google-maps-image-api-url'; +import gcm from 'node-gcm'; +import { trim } from '/imports/ui/components/NotificationsObserver/util.js'; +import ravenLogger from '/imports/startup/server/ravenLogger'; + +let validFcmSender = true; + +if (!(Meteor.settings.private.fcmApiToken && Meteor.settings.private.fcmApiToken.length > 0)) { + console.warn('Missing settings.private.fcmApiToken key, mobile notifications will not work'); + validFcmSender = false; +} + +// https://www.npmjs.com/package/google-maps-image-api-url +// https://stackoverflow.com/questions/24355007/is-there-no-way-to-embed-a-google-map-into-an-html-email +// https://developers.google.com/maps/documentation/static-maps/intro +function imgUrl(lat, lng) { + return image({ + key: Meteor.settings.gmaps.key, + type: 'staticmap', + center: `${lat},${lng}`, + size: '640x480', + zoom: 16, + maptype: 'hybrid', + language: 'es', + markers: `icon: ${Meteor.settings.private.fireIconUrl}|${lat},${lng}` + }); +} + +/* + function imgEl(lat, lng) { + return ``; + } */ + +// Cutover flag (fase 1a): channels listed in +// Meteor.settings.private.notifDisabledChannels (e.g. ['mobile','web']) are +// handled by the tcef-notifications microservice, so the old observer/cron must +// NOT send them. Mutual exclusion per channel — reversible by editing settings, +// no code deploy needed. ES5-compatible on purpose (Meteor 1.6 / Node 8). +const notifDisabledChannels = (Meteor.settings.private && Meteor.settings.private.notifDisabledChannels) || []; + +const processNotif = (notif) => { + if (notifDisabledChannels.indexOf(notif.type) !== -1) { + // This channel was migrated to tcef-notifications; do nothing here. + return; + } + if (isMailServerMaster && validFcmSender && notif.type === 'mobile' && notif.notified !== true) { + const fcmSender = new gcm.Sender(Meteor.settings.private.fcmApiToken); + const user = Meteor.users.findOne({ _id: notif.userId }); + moment.locale(user.lang); + // duplicate code below + const body = `${trim(notif.content)}`; + + // https://firebase.google.com/docs/cloud-messaging/concept-options + const msg = new gcm.Message(); + + msg.addNotification({ + title: i18n.t('Alerta de fuego'), + body, + click_action: 'FLUTTER_NOTIFICATION_CLICK', + tag: notif._id, // prevent duplication of fire notifications + sound: 'default', // Indicates sound to be played. Supports only default currently. + icon: 'launch_image' // 'ic_launcher' + }); + + msg.addData('id', notif._id._str); + msg.addData('description', body); + msg.addData('lat', notif.geo.coordinates[1]); + msg.addData('lon', notif.geo.coordinates[0]); + msg.addData('when', notif.when); + msg.addData('subsId', notif.subsId._str); + msg.addData('sealed', notif.sealed); + + const registrationTokens = []; + if (!user.fireBaseToken) { + console.warn('This mobile user doesn\'t have a firebase registration token'); + } else { + registrationTokens.push(user.fireBaseToken); + // FIXME: better join users + if (validFcmSender) { + fcmSender.send(msg, { registrationTokens }, Meteor.bindEnvironment(function processResult(err, response) { + if (err) { + console.error(`FCM error: ${err}`); + // FIXME send to sentry + ravenLogger.log(err); + } else { + // console.log(`FCM response: ${response}`); + Notifications.update(notif._id, { $set: { notified: true, notifiedAt: new Date() } }); + } + })); + } + } + } + if (isMailServerMaster && notif.type === 'web' && notif.emailNotified !== true) { + const user = Meteor.users.findOne({ _id: notif.userId }); + const { firstName, emailAddress } = getEmailOf(user); + + if (emailAddress) { + const img = imgUrl(notif.geo.coordinates[1], notif.geo.coordinates[0]); + // const url = imgUrl(notif.geo.coordinates[1], notif.geo.coordinates[0]); + const fireUrl = `${Meteor.absoluteUrl('fire/')}${notif.sealed}`; + // const fireHtmlUrl = `${i18n.t('Más información sobre este fuego')}`; + // TODO get _id of fire + // const fireTextUrl = `${i18n.t('Más información sobre este fuego')}:\n${fireUrl}`; + // FIXME use our map as url and static map as img + moment.locale(user.lang); + // moment user tz ? + const message = `${trim(notif.content)} (${i18n.t('fireDetectedAt', { when: dateLongFormat(notif.when) })}).`; + + // TODO Comunes Address + + const emailOpts = { + to: emailAddress, + // userName: firstName, + // sendAt: new Date(), + subject: subjectTruncate.apply(message), + // text: `${message}\n\n${fireTextUrl}\n\n`, + // template: '

{{appName}}

{{{html}}}', + lang: user.lang, + template: 'new-fire', + templateVars: { + applicationName: i18n.t('AppName'), + firstName, + message, + fireUrl, + img, + subsUrl: Meteor.absoluteUrl('subscriptions') + } + }; + sendEmail(emailOpts).catch((error) => { + throw new Meteor.Error('500', `${error}`); + }); + // sendMail(emailOpts, true); + Notifications.update(notif._id, { $set: { emailNotified: true, emailNotifiedAt: new Date() } }); + } else { + // Not email or not verified -> remove notif so we don't retry to send it + Notifications.remove({ _id: notif._id }); + } + } +}; + +export default processNotif; diff --git a/imports/startup/client/Gkeys.js b/imports/startup/client/Gkeys.js index aa9325e..0a1f83e 100644 --- a/imports/startup/client/Gkeys.js +++ b/imports/startup/client/Gkeys.js @@ -6,13 +6,6 @@ import GoogleMapsLoader from 'google-maps'; class GkeysC { constructor() { this.gmapkey = new ReactiveVar(null); - // Separate from the key itself: a deployment with no gmaps key configured - // still loads the script and still has to render. Waiting on the key value - // meant that with an empty key every component mounted after the script had - // loaded (i.e. any client-side navigation) sat forever on "Waiting for the - // gkey" and rendered an empty
— /subscriptions/new was blank, with no - // error anywhere. - this.loaded = new ReactiveVar(false); const self = this; this.callbacks = []; Meteor.startup(() => { @@ -20,14 +13,8 @@ class GkeysC { // console.log(google.maps); GoogleMapsLoader.KEY = key; GoogleMapsLoader.LIBRARIES = ['places']; - // The google-maps npm package (v3.2.1) builds the script URL without - // `loading=async`, which Google now warns about. It exposes no hook for - // extra params, so wrap createUrl to append it (no node_modules edit). - const origCreateUrl = GoogleMapsLoader.createUrl; - GoogleMapsLoader.createUrl = () => `${origCreateUrl()}&loading=async`; GoogleMapsLoader.load(() => { self.gmapkey.set(key); - self.loaded.set(true); console.log('GMaps script just loaded'); self.doCallbacks(key); }); @@ -45,12 +32,14 @@ class GkeysC { load(callback) { this.callbacks.push(callback); Tracker.autorun((computation) => { - if (this.loaded.get()) { - // already loaded; the key may legitimately be empty - this.doCallbacks(this.gmapkey.get()); + const key = this.gmapkey.get(); + if (key) { + // already loaded + // console.log('GMaps already loaded'); + this.doCallbacks(key); computation.stop(); } else { - console.log('Waiting for the gmaps script'); + console.log('Waiting for the gkey'); } }); } diff --git a/imports/startup/client/comments.js b/imports/startup/client/comments.js new file mode 100644 index 0000000..94cbe90 --- /dev/null +++ b/imports/startup/client/comments.js @@ -0,0 +1,42 @@ +/* global Comments */ +/* eslint-disable import/no-absolute-path */ + +import i18n from '/imports/startup/client/i18n'; +import '/imports/startup/common/comments'; + +import './comments.scss'; + +i18n.init((err, t) => { + Comments.ui.setContent({ + title: ' ', // i18n.t('Comentarios'), + save: t('Guardar'), + reply: t('Responder'), + edit: t('Editar'), + remove: t('Borrar'), + 'placeholder-textarea': t('Añadir un comentario'), + 'add-button-reply': t('Añadir una respuesta'), + 'add-button': t('Añadir comentario'), + 'you-need-to-login': t('Necesitas iniciar sesión para'), + 'add comments': t('añadir comentarios'), + 'like comments': t('puntuar comentarios'), + 'rate comments': t('puntuar comentarios'), + 'add replies': t('responder'), + 'load-more': t('Más comentarios') + }); + + /* This in client side */ + Comments.ui.config({ + limit: 20, // default 10 + loadMoreCount: 20, // default 20 + /* generateAvatar: function genAvatar(user, isAnonymous) { + if (isAnonymous) { + return i18n.t('Anónimo'); + } + return user.profile && user.profile.name && user.profile.name.first ? user.profile.name.first : null; + }, */ + template: 'bootstrap', // default 'semantic-ui' + // default 'http://s3.amazonaws.com/37assets/svn/765-default-avatar.png' + defaultAvatar: '/default-avatar.png', + markdown: true + }); +}); diff --git a/imports/startup/client/comments.scss b/imports/startup/client/comments.scss new file mode 100644 index 0000000..de93e65 --- /dev/null +++ b/imports/startup/client/comments.scss @@ -0,0 +1,29 @@ +.comments-section { + width: 100%; +} + +.comments-box { + max-width: inherit; + padding: 0; +} + +p.comment-content { + /* white-space: pre-line; */ +} + +/* remove comment btn danger style */ + +div.media-body.comment> div> div> div.btn.btn-danger.remove-action { + background-color: white; + border: 1px solid rgb(204, 204, 204); + color: rgb(51, 51, 51); +} + +/* text-area height */ +.form-control .create-comment { + height: 9em; +} + +.img-avatar { + margin-right: 5px; +} \ No newline at end of file diff --git a/imports/startup/client/i18n.js b/imports/startup/client/i18n.js index 1ddf4ed..384053f 100644 --- a/imports/startup/client/i18n.js +++ b/imports/startup/client/i18n.js @@ -1,10 +1,11 @@ -/* global Intl */ +/* global CookieConsent Intl */ import i18n from 'i18next'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; import { ReactiveVar } from 'meteor/reactive-var'; -import backend from 'i18next-http-backend'; +import backend from 'i18next-xhr-backend'; import LngDetector from 'i18next-browser-languagedetector'; +import Cache from 'i18next-localstorage-cache'; import { T9n } from 'meteor-accounts-t9n'; import moment from 'moment'; import 'moment/locale/es'; @@ -30,9 +31,21 @@ const detectorOptions = { export const i18nReady = new ReactiveVar(false); +const cacheOptions = { + // turn on or off + enabled: false, + // prefix for stored languages + prefix: 'i18next_res_', + // expiration + expirationTime: 7 * 24 * 60 * 60 * 1000, + // language versions + versions: {} +}; + +i18nOpts.cache = cacheOptions; i18nOpts.detection = detectorOptions; i18nOpts.react = { - useSuspense: false, // was `wait: true` pre-react-i18next-10 + wait: true, defaultTransParent: 'span' // https://react.i18next.com/components/i18next-instance.ht /* bindI18n: 'languageChanged loaded', @@ -42,17 +55,13 @@ i18nOpts.react = { const sendMissing = true; // Meteor.isDevelopment; if (sendMissing && Meteor.isDevelopment) { - i18nOpts.saveMissing = true; - // Modern signature is (lngs, ns, key, fallbackValue, ...); key/fallback keep - // the same positions, so we still only need those two. - i18nOpts.missingKeyHandler = function miss(lngs, ns, key, defaultValue) { + i18nOpts.sendMissing = true; + i18nOpts.missingKeyHandler = function miss(lng, ns, key, defaultValue) { Meteor.call('utility.saveMissingI18n', key, defaultValue); }; } function setT9(lang) { - // meteor-accounts-t9n has no Galician build, so use Spanish for gl account - // error messages (the closest shipped language). See common/i18n.js. if (lang === 'gl') { T9n.setLanguage('es'); } else { @@ -62,6 +71,7 @@ function setT9(lang) { i18n.use(backend) .use(LngDetector) + .use(Cache) .init(i18nOpts, (err, t) => { // initialized and ready to if (err) { @@ -79,9 +89,23 @@ i18n.use(backend) i18nReady.set(true); - // Cookie consent banner: selaias:cookie-consent (Blaze, dead on Meteor 3) - // replaced by the React component imports/ui/components/CookieConsent, - // which reuses the same i18n keys. + // cookies eu consent + const cookiesOpt = { + cookieTitle: t('Uso de Cookies'), + cookieMessage: t('Utilizamos cookies para asegurar un mejor uso de nuestra web. Si continúas navegando, consideramos que aceptas su uso'), + /* cookieMessage: t('Uso de Cookies'), + cookieMessageImply: t('Utilizamos cookies para asegurar un mejor uso de nuestra web. Si continúas navegando, consideramos que aceptas su uso'), */ + showLink: false, + position: 'bottom', + linkText: 'Lee más', + linkRouteName: '/privacy', + acceptButtonText: t('Aceptar'), + html: false, + expirationInDays: 70, + forceShow: false + }; + + CookieConsent.init(cookiesOpt); }); Meteor.subscribe('userData'); // lang is there diff --git a/imports/startup/client/index.js b/imports/startup/client/index.js index 4b474be..147cf4f 100644 --- a/imports/startup/client/index.js +++ b/imports/startup/client/index.js @@ -1,11 +1,6 @@ /* global */ -// Bootstrap 5 CSS (npm) replaces the old `alexwine:bootstrap-4` meteor package. -// Import it first so the app + component stylesheets below (and react-bootstrap, -// which targets BS5) override it. -import 'bootstrap/dist/css/bootstrap.css'; import React from 'react'; -import { createRoot } from 'react-dom/client'; -import { HelmetProvider } from 'react-helmet-async'; +import { render } from 'react-dom'; import { Meteor } from 'meteor/meteor'; import '/imports/startup/client/modernizr'; import App from '../../ui/layouts/App/App'; @@ -20,9 +15,5 @@ else { Modernizr.on('webp', (result) => { if (Meteor.isDevelopment) console.log(`webp ${result ? '' : 'not '}supported`); }); - Meteor.startup(() => createRoot(document.getElementById('react-root')).render( - - - - )); + Meteor.startup(() => render(, document.getElementById('react-root'))); } diff --git a/imports/startup/client/ravenLogger.js b/imports/startup/client/ravenLogger.js index 8e8efb7..d678591 100644 --- a/imports/startup/client/ravenLogger.js +++ b/imports/startup/client/ravenLogger.js @@ -1,37 +1,16 @@ -// Error reporter (client). Was flowkey:raven; replaced by @sentry/browser -// behind the same `.log()` facade (see the server counterpart). Enabled only -// when Meteor.settings.public.sentryPublicDSN is set; otherwise a plain console -// logger. ErrorBoundary calls `.log(error, info)` where info is React's -// { componentStack } — passed to Sentry as extra context. -import * as Sentry from '@sentry/browser'; +import RavenLogger from 'meteor/flowkey:raven'; import { Meteor } from 'meteor/meteor'; -const dsn = Meteor.settings.public.sentryPublicDSN; -const enabled = !!dsn; +const ravenOptions = {}; +const publicDSN = Meteor.settings.public.sentryPublicDSN; +const enabled = !!publicDSN; -if (enabled) { - Sentry.init({ - dsn, - environment: Meteor.isDevelopment ? 'development' : 'production', - tracesSampleRate: 0, - // Send events same-origin to our own server, which forwards them to - // GlitchTip. GlitchTip is fronted by Cloudflare, whose bot protection - // returns 503 to the browser's cross-site ingest beacons (server-side - // requests pass fine). The tunnel sidesteps that entirely (and ad-blockers). - // See imports/startup/server/sentryTunnel.js. - tunnel: '/sentry-tunnel' - }); -} +const ravenLogger = enabled ? new RavenLogger({ + publicDSN, // will be used on the client + shouldCatchConsoleError: true, // default + trackUser: true // default +}, ravenOptions) : { log: (error) => { console.log(error); } }; -const ravenLogger = { - log(error, info) { - console.log(error); - if (enabled) { - Sentry.captureException(error, info ? { extra: { info } } : undefined); - } - } -}; - -console.log(`sentryLogger (client) ${enabled ? 'enabled' : 'disabled'}`); +console.log(`ravenLogger ${enabled ? 'enabled' : 'disabled'}`); export default ravenLogger; diff --git a/imports/startup/common/comments.js b/imports/startup/common/comments.js new file mode 100644 index 0000000..7b9ab18 --- /dev/null +++ b/imports/startup/common/comments.js @@ -0,0 +1,21 @@ +/* global Comments */ + +// Client and Server +Comments.config({ + rating: 'likes-and-dislikes', + allowAnonymous: () => false, + allowReplies: () => false, // disabled right now: I don't know hot to get referenceId of replies + anonymousSalt: 'klasddl3lala0l3lasdlas0ol3lasdlao3lasdoaslaldal3lasdclasdlal3lasdladlaq', + publishUserFields: { + profile: 1 + }, + mediaAnalyzers: [ + Comments.analyzers.image, + Comments.analyzers.youtube + ], + generateUsername: function genUser(user) { + // console.log(JSON.stringify(user)); + // FIXME + return user.profile && user.profile.name && user.profile.name.first ? user.profile.name.first : ''; + } +}); diff --git a/imports/startup/common/i18n.js b/imports/startup/common/i18n.js index e9bb761..218e46d 100644 --- a/imports/startup/common/i18n.js +++ b/imports/startup/common/i18n.js @@ -3,9 +3,7 @@ import moment from 'moment'; // Load the js langs import es from 'meteor-accounts-t9n/build/es'; import en from 'meteor-accounts-t9n/build/en'; -// meteor-accounts-t9n (2.6.0) ships no Galician build (build/gl.js), so account -// error messages fall back to Spanish for gl — see setT9() in client/i18n.js. -// Re-enable this import if the upstream package ever adds a gl translation. +// TODO ask for translation of this // import gl from 'meteor-accounts-t9n/build/gl'; const backOpts = { @@ -42,17 +40,13 @@ const i18nOpts = { return value; } }, - supportedLngs: ['es', 'en', 'gl'], // allowed languages (was `whitelist` pre-i18next-21) + whitelist: ['es', 'en', 'gl'], // allowed languages load: 'languageOnly', // 'es' o 'en', previously: 'all', // es-ES -> es, en-US -> en debug: shouldDebug, ns: 'common', defaultNS: 'common', saveMissing: shouldDebug, // if true seems it's fails to getResourceBundle saveMissingTo: 'es', - // Our locale JSON uses natural-language (Spanish) keys and the v3 plural - // layout (`key_plural`), so keep i18next on the v3 JSON format instead of - // the v4 suffix scheme (`key_one`/`key_other`). - compatibilityJSON: 'v3', keySeparator: 'ß', nsSeparator: 'ð', pluralSeparator: 'đ' diff --git a/imports/startup/server/IPGeocoder.js b/imports/startup/server/IPGeocoder.js index 59a99af..96c5ef1 100644 --- a/imports/startup/server/IPGeocoder.js +++ b/imports/startup/server/IPGeocoder.js @@ -21,16 +21,11 @@ function isPrivateIP(ip) { const dbpath = '/usr/local/share/maxmind-geolite2/GeoLite2-City.mmdb'; // const dbpath = `${process.env.PWD}/private/GeoLite2-City.mmdb`; -let IPGeocoder; -if (fs.existsSync(dbpath)) { - IPGeocoder = maxmind.openSync(dbpath); -} else { - // In production the DB is provisioned via cron; in local dev it may be - // absent. Degrade gracefully instead of crashing at boot: localize() below - // already falls back to a default location when no geo data is available. +if (!fs.existsSync(dbpath)) { console.error(`Maxmind db not found ${dbpath}, download via cron with https://www.npmjs.com/package/maxmind-geolite2-mirror`); - IPGeocoder = { get() { return null; } }; } + +const IPGeocoder = maxmind.openSync(dbpath); export default IPGeocoder; // Warning: Meteor cannot access to this.connection with arrow functions @@ -52,7 +47,7 @@ export function localize() { // http://dev.maxmind.com/geoip/geoip2/geolite2/ const geo = IPGeocoder.get(clientIP); // console.warn(geo); - if (geo && geo.location && geo.location.latitude && geo.location.longitude) { + if (geo.location && geo.location.latitude && geo.location.longitude) { return geo; } // geoIP fallback, Madrid diff --git a/imports/startup/server/accounts/oauth.js b/imports/startup/server/accounts/oauth.js index bc541b4..545519a 100644 --- a/imports/startup/server/accounts/oauth.js +++ b/imports/startup/server/accounts/oauth.js @@ -4,12 +4,10 @@ import { ServiceConfiguration } from 'meteor/service-configuration'; const OAuthSettings = Meteor.settings.private.OAuth; if (OAuthSettings) { - // Meteor 3: sync Mongo write-methods are gone on the server; use *Async. - // Top-level await is supported in eager server modules. - for (const service of Object.keys(OAuthSettings)) { - await ServiceConfiguration.configurations.upsertAsync( + Object.keys(OAuthSettings).forEach((service) => { + ServiceConfiguration.configurations.upsert( { service }, { $set: OAuthSettings[service] }, ); - } + }); } diff --git a/imports/startup/server/api.js b/imports/startup/server/api.js index 88b68b6..a6044b4 100644 --- a/imports/startup/server/api.js +++ b/imports/startup/server/api.js @@ -15,6 +15,8 @@ import '../../api/FireAlerts/server/publications'; import '../../api/Subscriptions/methods'; import '../../api/Subscriptions/server/publications'; +// TODO add rate-limit to these publications + import '../../api/Notifications/methods'; import '../../api/Notifications/server/publications'; @@ -26,26 +28,3 @@ import '../../api/SiteSettings/server/publications'; import '../../api/FalsePositives/methods'; import '../../api/FalsePositives/server/publications'; - -import { rateLimitSubscriptions } from '../../modules/rate-limit'; - -// Rate-limit the abusable publications (the old TODO above). Single-fire -// lookups and the comments feed are cheap but brute-forceable, so keep them -// tight; the geo subs fire on every map pan/zoom, so give them more headroom. -rateLimitSubscriptions({ - subscriptions: [ - 'fireFromHash', 'fireFromAlertId', 'fireFromActiveId', 'fireFromId', - 'comments.forReference' - ], - limit: 5, - timeRange: 1000 -}); - -rateLimitSubscriptions({ - subscriptions: [ - 'activefiresmyloc', 'activefiresunionmyloc', 'fireAlerts', - 'falsePositivesMyloc', 'industriesMyloc' - ], - limit: 10, - timeRange: 1000 -}); diff --git a/imports/startup/server/calcUnionAsync.js b/imports/startup/server/calcUnionAsync.js deleted file mode 100644 index a24868a..0000000 --- a/imports/startup/server/calcUnionAsync.js +++ /dev/null @@ -1,99 +0,0 @@ -import path from 'path'; -import { Worker } from 'worker_threads'; -import { Meteor } from 'meteor/meteor'; - -const WORKER_PATH = path.resolve(process.cwd(), 'assets/app/workers/unionWorker.js'); - -// The app's own npm dependencies (installed by `meteor build`) live in -// programs/server/npm/node_modules — a directory that's a SIBLING of -// programs/server/assets, not an ancestor of it. Node's require() only walks -// up parent directories looking for node_modules, so a plain -// require('@turf/circle') from the worker script (a raw asset, not part of -// Meteor's own module system that knows about this layout) can never find it -// and fails with "Cannot find module '@turf/circle'". Resolving the absolute -// path here, where Meteor's own module system already knows how to find it, -// and handing it to the worker sidesteps that entirely. -const NPM_MODULES = path.resolve(process.cwd(), 'npm/node_modules'); -const turfPaths = { - circle: path.join(NPM_MODULES, '@turf/circle'), - union: path.join(NPM_MODULES, '@turf/union'), - truncate: path.join(NPM_MODULES, '@turf/truncate') -}; - -// A worker that never answers used to hang the promise forever, and with it the -// queue in subsUnionLogic: `busy` stays true and no union is ever computed again -// until the process restarts — silently, because nothing throws. Measured on -// this hardware (bench/union-bench.js), a full recreate of 10.000 subscriptions -// takes ~40 s, so ten minutes is a very wide margin over anything healthy while -// still being a bound. -const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; -export const timeoutMs = () => ( - (Meteor.settings.private && Meteor.settings.private.unionWorkerTimeoutMs) || DEFAULT_TIMEOUT_MS -); - - -class UnionWorkerTimeout extends Error { - constructor(ms) { - super(`union worker did not answer in ${ms} ms`); - this.name = 'UnionWorkerTimeout'; - this.isTimeout = true; - } -} - -// Cota de heap del worker. No es para que quepa: es para que un caso patológico -// (miles de suscripciones que no se solapan) muera con ERR_WORKER_OUT_OF_MEMORY -// —que esta capa convierte en un rechazo y deja la unión anterior intacta— en -// vez de que el kernel se lleve por delante el proceso entero de Meteor. Medido: -// 10.000 suscripciones con 64 vértices por círculo son ~421 MB de RSS de todo el -// proceso, así que 1 GB de heap para el worker es holgado. -const DEFAULT_MAX_HEAP_MB = 1024; -const settingsOf = name => (Meteor.settings.private || {})[name]; - -const runOnce = (subs, baseUnion, ms) => new Promise((resolve, reject) => { - const worker = new Worker(WORKER_PATH, { - workerData: { subs, baseUnion, turfPaths, steps: settingsOf('unionCircleSteps') }, - resourceLimits: { maxOldGenerationSizeMb: settingsOf('unionWorkerMaxHeapMb') || DEFAULT_MAX_HEAP_MB } - }); - let settled = false; - const finish = (fn, arg) => { - if (settled) return; - settled = true; - clearTimeout(timer); - // terminate() is what actually stops the thread burning a core; without it a - // timed-out union would keep computing forever next to its replacement. - worker.terminate(); - fn(arg); - }; - const timer = setTimeout(() => finish(reject, new UnionWorkerTimeout(ms)), ms); - - worker.once('message', (msg) => { - if (msg.error) finish(reject, new Error(msg.error)); - else finish(resolve, msg.union); - }); - worker.once('error', err => finish(reject, err)); -}); - -/** - * Circle-union of already-validated, already-decorated subscriptions, run in a - * worker thread so the main event loop (DDP/HTTP) stays free regardless of - * how long any single turf.union call takes. subs must be an array of plain - * { location: { lat, lon }, distance } objects. If baseUnion is given, subs - * are merged into it instead of a union computed from scratch. - * - * Fails after a timeout, and retries once on a genuine error — a failure to - * spawn the thread, or a crash, is usually transient. A TIMEOUT is not retried: - * whatever made it take longer than the bound will still be there, and a second - * attempt only doubles the time the union stays stale. - */ -const calcUnionAsync = async (subs, baseUnion = null) => { - const ms = timeoutMs(); - try { - return await runOnce(subs, baseUnion, ms); - } catch (e) { - if (e.isTimeout) throw e; - console.warn('union worker failed, retrying once', e.message); - return runOnce(subs, baseUnion, ms); - } -}; - -export default calcUnionAsync; diff --git a/imports/startup/server/comments.js b/imports/startup/server/comments.js new file mode 100644 index 0000000..b010405 --- /dev/null +++ b/imports/startup/server/comments.js @@ -0,0 +1,80 @@ +/* global Comments */ +/* eslint-disable import/no-absolute-path */ + +import { Meteor } from 'meteor/meteor'; +import i18n from 'i18next'; +import sendEmail, { subjectTruncate } from '/imports/modules/server/send-email'; +import getEmailOf from '/imports/modules/get-email-of-user'; +import '../common/comments'; + +/* import i18n from 'i18next'; +import moment from 'moment'; +import { dateLongFormat } from '/imports/api/Common/dates'; +import Notifications from '/imports/api/Notifications/Notifications'; +// import sendMail from '/imports/startup/server/email'; +// import { hr } from '/imports/startup/server/email'; +import getOAuthProfile from '/imports/modules/get-oauth-profile'; +import image from 'google-maps-image-api-url'; +import { trim } from '/imports/ui/components/NotificationsObserver/util.js'; */ + +Meteor.startup(() => { + // On Server or Client (preferably on Server) + Comments.config({ + onEvent: (name, action, payload) => { + // e.g send a mail + // console.log(`name: ${name}, action: ${action}, payload: ${JSON.stringify(payload)}`); + + // Samples: + + // name: comment, action: add, payload: {"referenceId":"fire-56a9cc5a5586879eb9cc6dd7","content":"asdfasdf asd fasd fas","userId":"tdK4e43ikjDXct7Gj","isAnonymous":false,"createdAt":"2018-03-04T14:53:40.319Z","likes":[],"dislikes":[],"replies":[],"media":{},"status":"approved","starRatings":[],"ratingScore":0,"_id":"mfAe3HL9qNofhiAnh"} + // name: reply, action: add, payload: {"replyId":"teSLaz4f8RxHjptBm","content":"sas fasdfasdasdf","userId":"tdK4e43ikjDXct7Gj","createdAt":"2018-03-04T14:54:56.965Z","replies":[],"likes":[],"lastUpdatedAt":"2018-03-04T14:54:56.965Z","isAnonymous":false,"status":"approved","media":{},"ratingScore":0,"_id":"DyHt28rF6rTDnC5fP","rootUserId":"tdK4e43ikjDXct7Gj"} + // name: reply, action: like, payload: {"replyId":"teSLaz4f8RxHjptBm","content":"sas fasdfasdasdf","userId":"tdK4e43ikjDXct7Gj","createdAt":"2018-03-04T14:54:56.965Z","replies":[],"likes":["tdK4e43ikjDXct7Gj"],"lastUpdatedAt":"2018-03-04T14:54:56.965Z","isAnonymous":false,"status":"approved","media":{},"ratingScore":1,"_id":"DyHt28rF6rTDnC5fP","ratedUserId":"tdK4e43ikjDXct7Gj","rootUserId":"tdK4e43ikjDXct7Gj"} + // name: comment, action: like, payload: {"_id":"mfAe3HL9qNofhiAnh","ratedUserId":"tdK4e43ikjDXct7Gj"} + // name: reply, action: edit, payload: {"_id":"DyHt28rF6rTDnC5fP","replyId":"teSLaz4f8RxHjptBm","content":"sas fasdfasdasdf dasd fadsfa","userId":"tdK4e43ikjDXct7Gj","createdAt":"2018-03-04T14:54:56.965Z","replies":[],"likes":[],"lastUpdatedAt":"2018-03-04T14:54:56.965Z","isAnonymous":false,"status":"approved","media":{},"ratingScore":0,"starRatings":[],"ratedUserId":"tdK4e43ikjDXct7Gj","rootUserId":"tdK4e43ikjDXct7Gj"} + // name: reply, action: remove, payload: {"_id":"DyHt28rF6rTDnC5fP","rootUserId":"tdK4e43ikjDXct7Gj"} + + // name: (comment|reply) + // action: (add|like|edit|remove) + + if (name === 'comment' && action.match(/add|like|edit/)) { + if (action === 'add') { + // Check for other users that did comments and send an email (uniq users, and not this user) + // console.log(payload.referenceId); + const query = payload.isAnonymous ? + { referenceId: payload.referenceId } : + { referenceId: payload.referenceId, userId: { $ne: payload.userId } }; + Comments.getCollection().rawCollection().distinct('userId', query).then((users) => { + // console.log(users); + const path = payload.referenceId.replace(/fire-/, 'fire/archive/'); + const fireUrl = Meteor.absoluteUrl(path); + // console.log(fireUrl); + Meteor.users.find({ _id: { $in: users } }).forEach((user) => { + const { firstName, emailAddress } = getEmailOf(user); + // console.log(JSON.stringify(user)); + if (emailAddress) { + const emailOpts = { + to: emailAddress, + subject: subjectTruncate.apply(i18n.t('Hay más información sobre un fuego')), + lang: user.lang, + template: 'new-fire-comment', + templateVars: { + applicationName: i18n.t('AppName'), + firstName, + fireUrl + } + }; + sendEmail(emailOpts).catch((error) => { + throw new Meteor.Error('500', `${error}`); + }); + } + }); + }); + } + } + if (name === 'reply' && action.match(/add|like|edit/)) { + // Check for other previous users to this thread and send an email + // console.log('Reply op'); + } + } + }); +}); diff --git a/imports/startup/server/cron.js b/imports/startup/server/cron.js index 0004916..843e55b 100644 --- a/imports/startup/server/cron.js +++ b/imports/startup/server/cron.js @@ -4,6 +4,8 @@ import { Meteor } from 'meteor/meteor'; import { tweetIberiaFires, tweetEuropeFires } from '/imports/api/ActiveFires/server/tweetFiresInZone'; import { isMailServerMaster, sendEmailsFromQueue } from '/imports/startup/server/email'; +import Notifications from '/imports/api/Notifications/Notifications'; +import processNotif from '/imports/modules/server/notificationsProcess.js'; // https://github.com/thesaucecode/meteor-synced-cron/ @@ -53,11 +55,29 @@ Meteor.startup(() => { job: () => sendEmailsFromQueue() }); - // NOTE: the "Process pending notif" job (push via node-gcm + notification - // emails) was removed here — that responsibility now lives in the - // tcef-notifications microservice (fases 1a-1c). See UPGRADE.md for the - // deploy-ordering dependency (the old code must not be shut off in - // production until the new service is emitting). + SyncedCron.add({ + name: 'Process pending notif', + timezone: 'Europe/Madrid', + schedule: (parser) => { + // http://bunkat.github.io/later/ + const sched = parser.text('every 15 min'); + if (sched.error !== -1) { + console.error(`Mail cron 'when' field parsed with errors: ${sched.error}`); + } + return sched; + }, + job: () => { + const mobileNotif = Notifications.find({ nofitied: null, type: 'mobile' }); + mobileNotif.forEach((notif) => { + processNotif(notif); + }); + const emailNotif = Notifications.find({ emailNofitied: null, type: 'web' }); + emailNotif.forEach((notif) => { + processNotif(notif); + }); + return { mobile: mobileNotif.count(), web: emailNotif.count() }; + } + }); } const esEn = Meteor.settings.private.twitter.es.enabled; diff --git a/imports/startup/server/email.js b/imports/startup/server/email.js index 8caf2f3..42752cd 100644 --- a/imports/startup/server/email.js +++ b/imports/startup/server/email.js @@ -1,6 +1,6 @@ import { Meteor } from 'meteor/meteor'; import nodemailer from 'nodemailer'; -import MailTime from 'meteor/ostrio:mailer'; +import { MailTime } from 'meteor/ostrio:mailer'; import i18n from 'i18next'; import isMaster from './isMaster'; @@ -48,8 +48,7 @@ if (isMailServerMaster) { concatDelimiter: hr + '

{{{subject}}}

', // Start each concatenated email with it's own subject /* eslint-enable */ // concatThrottling: 30, - // Mail-Time 2.x removed the static MailTime.Template; omitting `template` - // uses the built-in default ('{{{html}}}'). (debt: richer wrapper template) + template: MailTime.Template // Use default template }); } else { console.log('I\'m a mail client'); diff --git a/imports/startup/server/facts.js b/imports/startup/server/facts.js index ac3e9df..0c75843 100644 --- a/imports/startup/server/facts.js +++ b/imports/startup/server/facts.js @@ -1,18 +1,9 @@ +/* global Facts */ +import { Roles } from 'meteor/alanning:roles'; import { Meteor } from 'meteor/meteor'; -import { Facts } from 'meteor/facts-base'; -/* - * alanning:roles removed (1.x is dead on Meteor 3 — crashes the whole client - * bundle at load — and 4.x needs a data migration we don't want). Admin check - * against the plain `user.roles` field, cached at startup: facts are - * dev/admin instrumentation and the admin set essentially never changes. - */ -const adminIds = new Set(); - -Meteor.startup(async () => { - await Meteor.users - .find({ roles: 'admin' }, { fields: { _id: 1 } }) - .forEachAsync(u => adminIds.add(u._id)); +Facts.setUserIdFilter((userId) => { + const user = Meteor.users.findOne(userId); + // console.log(`User roles: ${user.roles}`); + return Roles.userIsInRole(userId, ['admin']); }); - -Facts.setUserIdFilter(userId => adminIds.has(userId)); diff --git a/imports/startup/server/fibers.js b/imports/startup/server/fibers.js new file mode 100644 index 0000000..ff96265 --- /dev/null +++ b/imports/startup/server/fibers.js @@ -0,0 +1,7 @@ +// Workaround for: https://github.com/meteor/meteor/issues/9796 +// https://github.com/meteor/meteor/issues/9796#issuecomment-381676326 +// https://github.com/sandstorm-io/sandstorm/blob/0f1fec013fe7208ed0fd97eb88b31b77e3c61f42/shell/server/00-startup.js#L99-L129 + +import Fiber from 'fibers'; + +Fiber.poolSize = 1e9; diff --git a/imports/startup/server/fixtures.js b/imports/startup/server/fixtures.js index 18f2683..d608ab1 100644 --- a/imports/startup/server/fixtures.js +++ b/imports/startup/server/fixtures.js @@ -1,71 +1,54 @@ -/* eslint-disable no-await-in-loop */ +import seeder from '@cleverbeagle/seeder'; import { Meteor } from 'meteor/meteor'; -import { Accounts } from 'meteor/accounts-base'; import Documents from '../../api/Documents/Documents'; -/* - * Dev/staging fixtures. Replaces @cleverbeagle/seeder (sync Mongo, no Meteor 3 - * support) with a small async seeder: idempotent (find-before-create), only - * runs outside production. - */ +const documentsSeed = userId => ({ + collection: Documents, + environments: ['development', 'staging'], + noLimit: true, + modelCount: 5, + model(dataIndex) { + return { + owner: userId, + title: `Document #${dataIndex + 1}`, + body: `This is the body of document #${dataIndex + 1}`, + }; + }, +}); -const USERS = [ - { +seeder(Meteor.users, { + environments: ['development', 'staging'], + noLimit: true, + data: [{ email: 'admin@admin.com', password: 'password', - profile: { name: { first: 'Andy', last: 'Warhol' } }, - roles: ['admin'] + profile: { + name: { + first: 'Andy', + last: 'Warhol', + }, + }, + roles: ['admin'], + data(userId) { + return documentsSeed(userId); + }, + }], + modelCount: 5, + model(index, faker) { + const userCount = index + 1; + return { + email: `user+${userCount}@test.com`, + password: 'password', + profile: { + name: { + first: faker.name.firstName(), + last: faker.name.lastName(), + }, + }, + roles: ['user'], + data(userId) { + return documentsSeed(userId); + }, + }; }, - ...[ - ['Frida', 'Kahlo'], - ['Joaquín', 'Sorolla'], - ['Maruja', 'Mallo'], - ['Remedios', 'Varo'], - ['Pablo', 'Picasso'] - ].map(([first, last], i) => ({ - email: `user+${i + 1}@test.com`, - password: 'password', - profile: { name: { first, last } }, - roles: ['user'] - })) -]; - -const DOCUMENTS_PER_USER = 5; - -async function ensureUser({ email, password, profile, roles }) { - const existing = await Meteor.users.findOneAsync({ 'emails.address': email }); - const userId = existing - ? existing._id - : await Accounts.createUserAsync({ email, password, profile }); - if (!existing && roles) { - await Meteor.users.updateAsync(userId, { $set: { roles } }); - } - return userId; -} - -async function ensureDocuments(userId) { - const count = await Documents.find({ owner: userId }).countAsync(); - if (count > 0) return; - for (let i = 0; i < DOCUMENTS_PER_USER; i += 1) { - await Documents.insertAsync({ - owner: userId, - title: `Document #${i + 1}`, - body: `This is the body of document #${i + 1}` - }); - } -} - -Meteor.startup(async () => { - // dev always; staging opts in with TCEF_SEED=1 (the old seeder's - // 'staging' environment had no reliable detection either). - if (!Meteor.isDevelopment && process.env.TCEF_SEED !== '1') return; - try { - for (const user of USERS) { - const userId = await ensureUser(user); - await ensureDocuments(userId); - } - console.log(`fixtures: ${USERS.length} dev users ensured`); - } catch (e) { - console.warn(`fixtures: seeding failed: ${e}`); - } }); diff --git a/imports/startup/server/i18n.js b/imports/startup/server/i18n.js index 90ac39a..053d487 100644 --- a/imports/startup/server/i18n.js +++ b/imports/startup/server/i18n.js @@ -1,6 +1,6 @@ import { Meteor } from 'meteor/meteor'; import i18n from 'i18next'; -import backend from 'i18next-fs-backend'; +import backend from 'i18next-sync-fs-backend'; import i18nOpts from '../common/i18n'; // import moment from 'moment'; // import { T9n } from 'meteor-accounts-t9n'; diff --git a/imports/startup/server/index.js b/imports/startup/server/index.js index b267122..7f84dde 100644 --- a/imports/startup/server/index.js +++ b/imports/startup/server/index.js @@ -1,15 +1,16 @@ import './ravenLogger'; -import './sentryTunnel'; import './catchExceptions'; +import './fibers'; import './i18n'; import './accounts'; import './api'; import './fixtures'; import './email'; -import '/imports/api/Comments/server'; import './IPGeocoder'; import './migrations'; +import './notificationsObserver'; import './facts'; +import './comments'; import './sitemaps'; import './subsUnion'; import './prerender'; diff --git a/imports/startup/server/migrations.js b/imports/startup/server/migrations.js index e67bad2..e3bfac9 100644 --- a/imports/startup/server/migrations.js +++ b/imports/startup/server/migrations.js @@ -1,8 +1,6 @@ -/* global Migrations */ +/* global Migrations, Comments */ /* eslint-disable import/no-absolute-path */ -/* eslint-disable no-await-in-loop, no-restricted-syntax */ import { Meteor } from 'meteor/meteor'; -import Comments from '/imports/api/Comments/Comments'; import { Accounts } from 'meteor/accounts-base'; import randomHex from 'crypto-random-hex'; import UserSubsToFiresCollection from '/imports/api/Subscriptions/Subscriptions'; @@ -15,7 +13,7 @@ import Notifications from '/imports/api/Notifications/Notifications'; import ActiveFiresUnion from '/imports/api/ActiveFiresUnion/ActiveFiresUnion'; import { Mongo } from 'meteor/mongo'; -Meteor.startup(async () => { +Meteor.startup(() => { // https://github.com/percolatestudio/meteor-migrations Migrations.config({ @@ -25,40 +23,38 @@ Meteor.startup(async () => { Migrations.add({ version: 1, - up: async function migrateIds() { + up: function migrateIds() { // https://docs.mongodb.com/manual/reference/operator/query/type/ - const users = await Meteor.users.find({ _id: { $type: 7 } }).fetchAsync(); - for (const user of users) { + Meteor.users.find({ _id: { $type: 7 } }).forEach((user) => { const migratedUser = user; const id = user._id.valueOf(); console.log(`Migrating id of user: ${JSON.stringify(user)}`); - await Meteor.users.removeAsync({ _id: user._id }); + Meteor.users.remove({ _id: user._id }); migratedUser._id = id; - await Meteor.users.insertAsync(migratedUser); - } + Meteor.users.insert(migratedUser); + }); } }); Migrations.add({ version: 2, - up: async function migrateSubsForeignKey() { - const subs = await UserSubsToFiresCollection.find({ owner: null }).fetchAsync(); - for (const sub of subs) { + up: function migrateSubsForeignKey() { + UserSubsToFiresCollection.find({ owner: null }).forEach((sub) => { console.log(`Migrating subs of chatId: ${sub.chatId}`); - const subsUser = await Meteor.users.findOneAsync({ telegramChatId: sub.chatId }); + const subsUser = Meteor.users.findOne({ telegramChatId: sub.chatId }); if (subsUser) { console.log(`Migrating linking to user: ${JSON.stringify(subsUser)}`); - await UserSubsToFiresCollection.updateAsync({ _id: sub._id }, { $set: { owner: subsUser._id } }); + UserSubsToFiresCollection.update({ _id: sub._id }, { $set: { owner: subsUser._id } }); } else { // create user with chatId and def language const username = `tel${sub.chatId.toString().replace(/^-/, '')}`; console.log(`Linking to new user: ${username}`); - const newUserId = await Accounts.createUserAsync({ + const newUserId = Accounts.createUser({ username, password: randomHex(50), profile: { name: {} } }); - await Meteor.users.updateAsync({ _id: newUserId }, { + Meteor.users.update({ _id: newUserId }, { $set: { emails: [], roles: ['user'], @@ -66,90 +62,87 @@ Meteor.startup(async () => { lang: 'es' } }); - await UserSubsToFiresCollection.updateAsync({ _id: sub._id }, { $set: { owner: newUserId } }); + UserSubsToFiresCollection.update({ _id: sub._id }, { $set: { owner: newUserId } }); } - } + }); } }); Migrations.add({ version: 3, - up: async function emptySubsTypes() { - const subs = await UserSubsToFiresCollection.find({ type: null }).fetchAsync(); - for (const sub of subs) { - await UserSubsToFiresCollection.updateAsync({ _id: sub._id }, { $set: { type: 'telegram' } }); - } + up: function emptySubsTypes() { + UserSubsToFiresCollection.find({ type: null }).forEach((sub) => { + UserSubsToFiresCollection.update({ _id: sub._id }, { $set: { type: 'telegram' } }); + }); } }); Migrations.add({ version: 4, - up: async function deleteOldAlertFiresAndIndexes() { - await FireAlertsCollection.removeAsync({ createdAd: null }); + up: function deleteOldAlertFiresAndIndexes() { + FireAlertsCollection.remove({ createdAd: null }); const raw = FireAlertsCollection.rawCollection(); - await raw.createIndex({ ourid: '2dsphere' }); - await raw.createIndex({ when: 1 }); - await raw.createIndex({ updatedAt: 1 }); - await raw.createIndex({ createdAt: 1 }); - await raw.createIndex({ ourid: 1, type: 1 }); + raw.createIndex({ ourid: '2dsphere' }); + raw.createIndex({ when: 1 }); + raw.createIndex({ updatedAt: 1 }); + raw.createIndex({ createdAt: 1 }); + raw.createIndex({ ourid: 1, type: 1 }); } }); Migrations.add({ version: 5, - up: async function siteSettingsIndex() { + up: function siteSettingsIndex() { // other way: - await SiteSettings.createIndexAsync({ name: 1 }, { unique: true }); + SiteSettings._ensureIndex({ name: 1 }, { unique: 1 }); } }); Migrations.add({ version: 6, - up: async function falsePositiveIndexes() { - await FalsePositives.createIndexAsync({ chatId: 1 }); - await FalsePositives.createIndexAsync({ owner: 1 }); - await FalsePositives.createIndexAsync({ fireId: 1 }); - await FalsePositives.createIndexAsync({ type: 1 }); - await FalsePositives.createIndexAsync({ geo: '2dsphere' }); + up: function falsePositiveIndexes() { + FalsePositives._ensureIndex({ chatId: 1 }); + FalsePositives._ensureIndex({ owner: 1 }); + FalsePositives._ensureIndex({ fireId: 1 }); + FalsePositives._ensureIndex({ type: 1 }); + FalsePositives._ensureIndex({ geo: '2dsphere' }); } }); Migrations.add({ version: 7, - up: async function defLangIfNull() { - const users = await Meteor.users.find({ lang: null }).fetchAsync(); - for (const user of users) { - await Meteor.users.updateAsync({ _id: user._id }, { + up: function defLangIfNull() { + Meteor.users.find({ lang: null }).forEach((user) => { + Meteor.users.update({ _id: user._id }, { $set: { lang: 'es' } }); - } + }); } }); Migrations.add({ version: 8, - up: async function siteSettingsAddIndex() { - await SiteSettings.createIndexAsync({ isPublic: 1 }); - const settings = await SiteSettings.find({ isPublic: null }).fetchAsync(); - for (const setting of settings) { - await SiteSettings.updateAsync({ _id: setting._id }, { $set: { isPublic: true } }); - } + up: function siteSettingsAddIndex() { + SiteSettings._ensureIndex({ isPublic: 1 }); + SiteSettings.find({ isPublic: null }).forEach((setting) => { + SiteSettings.update({ _id: setting._id }, { $set: { isPublic: true } }); + }); } }); Migrations.add({ version: 9, - up: async function industriesIndexesAndRegistries() { - await Industries.createIndexAsync({ registry: 1 }); - await Industries.createIndexAsync({ geo: '2dsphere' }); + up: function siteSettingsAddIndex() { + Industries._ensureIndex({ registry: 1 }); + Industries._ensureIndex({ geo: '2dsphere' }); // https://www.eea.europa.eu/data-and-maps/data/member-states-reporting-art-7-under-the-european-pollutant-release-and-transfer-register-e-prtr-regulation-16 - await IndustryRegistries.insertAsync({ + IndustryRegistries.insert({ _id: '1', name: 'E-PRTR', agency: 'EEA', region: 'EU' }); // https://www.epa.gov/enviro/epa-frs-facilities-state-single-file-csv-download - await IndustryRegistries.insertAsync({ + IndustryRegistries.insert({ _id: '2', name: 'FRS', agency: 'EPA', region: 'US' }); } @@ -157,11 +150,11 @@ Meteor.startup(async () => { Migrations.add({ version: 10, - up: async function moreIndustryRegistries() { - await IndustryRegistries.insertAsync({ + up: function siteSettingsAddIndex() { + IndustryRegistries.insert({ _id: '3', name: 'NPRI', agency: 'GCODP', region: 'Canada' }); - await IndustryRegistries.insertAsync({ + IndustryRegistries.insert({ _id: '4', name: 'NPI', agency: 'DEE', region: 'Australia' }); } @@ -169,15 +162,15 @@ Meteor.startup(async () => { Migrations.add({ version: 11, - up: async function noAnonComments() { - await Comments.removeAsync({ isAnonymous: true }); + up: function noAnonComments() { + Comments.getCollection().remove({ isAnonymous: true }); } }); Migrations.add({ version: 12, - up: async function setTelegramUsersBotId() { - await Meteor.users.updateAsync({ telegramChatId: { $ne: null }, telegramBot: null }, { + up: function setTelegramUsersBotId() { + Meteor.users.update({ telegramChatId: { $ne: null }, telegramBot: null }, { $set: { telegramBot: 'es' } @@ -187,8 +180,8 @@ Meteor.startup(async () => { Migrations.add({ version: 13, - up: async function removeTelegramBotFromUsersId() { - await Meteor.users.updateAsync({}, { + up: function removeTelegramBotFromUsersId() { + Meteor.users.update({}, { $unset: { telegramBot: '' } @@ -198,8 +191,8 @@ Meteor.startup(async () => { Migrations.add({ version: 14, - up: async function setTelegramSubsBotId() { - await UserSubsToFiresCollection.updateAsync({ chatId: { $ne: null }, telegramBot: null }, { + up: function setTelegramUsersBotId() { + UserSubsToFiresCollection.update({ chatId: { $ne: null }, telegramBot: null }, { $set: { telegramBot: 'es' } @@ -209,41 +202,40 @@ Meteor.startup(async () => { Migrations.add({ version: 15, - up: async function moveToFalsePositivesUppercase() { + up: function moveToFalsePositivesUppercase() { /* const falsepositiveslower = new Mongo.Collection('falsepositives', { idGeneration: 'MONGO' }); * falsepositiveslower.find({}).forEach((falseDoc) => { * FalsePositives.insert(falseDoc); - * }); */ + * });*/ // TODO remove falsepositives lowercase collection } }); Migrations.add({ version: 16, - up: async function moveToFalsePositivesUppercaseWithUser() { + up: function moveToFalsePositivesUppercaseWithUser() { const falsepositiveslower = new Mongo.Collection('falsepositives', { idGeneration: 'MONGO' }); const now = new Date(); - const falseDocs = await falsepositiveslower.find({}).fetchAsync(); - for (const falseDoc of falseDocs) { - const user = await Meteor.users.findOneAsync({ telegramChatId: falseDoc.chatId }); + falsepositiveslower.find({}).forEach((falseDoc) => { + const user = Meteor.users.findOne({ telegramChatId: falseDoc.chatId }); if (user) { - falseDoc.owner = user._id; + falseDoc.owner= user._id; } falseDoc.type = 'industry'; falseDoc.createdAt = now; falseDoc.updatedAt = now; - await FalsePositives.insertAsync(falseDoc); - } + FalsePositives.insert(falseDoc); + }); } }); Migrations.add({ version: 17, - up: async function renameWebNotifiedField() { - await Notifications.updateAsync({ webNotified: { $exists: true } }, { + up: function renameWebNotifiedField() { + Notifications.update({ webNotified: { $exists: true } }, { $rename: { webNotifiedAt: 'notifiedAt' } }, { upsert: false, multi: true }); - await Notifications.updateAsync({ webNotified: { $exists: true } }, { + Notifications.update({ webNotified: { $exists: true } }, { $rename: { webNotified: 'notified' } }, { upsert: false, multi: true }); } @@ -251,19 +243,18 @@ Meteor.startup(async () => { Migrations.add({ version: 18, - up: async () => { + up: () => { const raw = ActiveFiresUnion.rawCollection(); - await raw.createIndex({ centerid: '2dsphere' }); - await raw.createIndex({ shape: '2dsphere' }); - await raw.createIndex({ when: 1 }); - await raw.createIndex({ createdAt: 1 }); - await raw.createIndex({ updatedAt: 1 }); + raw.createIndex({ centerid: '2dsphere' }); + raw.createIndex({ shape: '2dsphere' }); + raw.createIndex({ when: 1 }); + raw.createIndex({ createdAt: 1 }); + raw.createIndex({ updatedAt: 1 }); } }); - // Meteor 3: migrateTo is async and awaits each async up(), so a fresh DB can - // migrate from 0 to latest without pre-marking the control version. - await Migrations.migrateTo('latest'); + // Set createdAt in users & subs + Migrations.migrateTo('latest'); // Migrations.migrateTo('14,rerun'); }); diff --git a/imports/startup/server/notificationsObserver.js b/imports/startup/server/notificationsObserver.js new file mode 100644 index 0000000..70be8e0 --- /dev/null +++ b/imports/startup/server/notificationsObserver.js @@ -0,0 +1,19 @@ +/* eslint-disable import/no-absolute-path */ + +import { Meteor } from 'meteor/meteor'; +import Notifications from '/imports/api/Notifications/Notifications'; +import processNotif from '/imports/modules/server/notificationsProcess.js'; +import { isMailServerMaster } from '/imports/startup/server/email'; + +Meteor.startup(() => { + if (isMailServerMaster) { + Notifications.find().observe({ + added: function notifAdded(notif) { + processNotif(notif); + }, + changed: function notifChanged(updatedNotif) { // , oldNotif) { + processNotif(updatedNotif); + } + }); + } +}); diff --git a/imports/startup/server/ravenLogger.js b/imports/startup/server/ravenLogger.js index c1a3e5b..b3b83a3 100644 --- a/imports/startup/server/ravenLogger.js +++ b/imports/startup/server/ravenLogger.js @@ -1,35 +1,18 @@ -/* eslint-disable import/no-absolute-path */ -// Error reporter (server). Was flowkey:raven (raven@2.4.1, Sentry legacy SDK), -// which is dead on Meteor 3 and, with a dead DSN, spammed the boot log with -// "failed to send exception to sentry: HTTP Error (502)". Replaced by the -// modern @sentry/node SDK behind the same `.log()` facade so the 6 call sites -// don't change. With no DSN configured it is a pure console logger (identical -// to the old "disabled" branch) — ready to point at a DSN when Sentry/GlitchTip -// is back, via Meteor.settings.sentryPrivateDSN. -import * as Sentry from '@sentry/node'; +import RavenLogger from 'meteor/flowkey:raven'; import { Meteor } from 'meteor/meteor'; -const dsn = Meteor.settings.sentryPrivateDSN; -const enabled = !!dsn; +const ravenOptions = {}; +const publicDSN = Meteor.settings.public.sentryPublicDSN; +const privateDSN = Meteor.settings.sentryPrivateDSN; +const enabled = publicDSN && privateDSN; -if (enabled) { - Sentry.init({ - dsn, - environment: Meteor.isDevelopment ? 'development' : 'production', - // errors only; no tracing/profiling (avoids the OTel auto-instrumentation) - tracesSampleRate: 0 - }); -} +const ravenLogger = enabled ? new RavenLogger({ + publicDSN, + privateDSN, + shouldCatchConsoleError: true, // default true + trackUser: true // default false +}, ravenOptions) : { log: (error) => { console.log(error); } }; -const ravenLogger = { - log(error, extra) { - console.error(error); - if (enabled) { - Sentry.captureException(error, extra ? { extra: { info: extra } } : undefined); - } - } -}; - -console.log(`sentryLogger (server) ${enabled ? 'enabled' : 'disabled'}`); +console.log(`ravenLogger ${enabled ? 'enabled' : 'disabled'}`); export default ravenLogger; diff --git a/imports/startup/server/sentryTunnel.js b/imports/startup/server/sentryTunnel.js deleted file mode 100644 index 9ffd5ba..0000000 --- a/imports/startup/server/sentryTunnel.js +++ /dev/null @@ -1,127 +0,0 @@ -/* eslint-disable import/no-absolute-path */ -import https from 'https'; -import { Meteor } from 'meteor/meteor'; -import { WebApp } from 'meteor/webapp'; - -/* - * Sentry tunnel: /sentry-tunnel receives the browser SDK's envelopes - * same-origin and forwards them to GlitchTip server-side. - * - * Why: GlitchTip lives behind Cloudflare (sentry.comunes.org). Cloudflare's - * bot protection answers the browser's cross-site ingest POSTs with 503 (no - * CORS headers -> "Failed to fetch"), while identical server-side requests get - * 200. Routing events through our own origin avoids the cross-site request - * (and ad-blockers) completely. The browser SDK opts in via `tunnel` in - * imports/startup/client/ravenLogger.js. - * - * Single-DSN app: the target is derived from sentryPublicDSN, so we never have - * to decompress/parse the envelope body — we just relay the raw bytes to the - * right project's ingest endpoint. - */ - -const MAX_BODY = 1024 * 1024; // 1 MB: Sentry envelopes are small; cap abuse. - -function ingestTargetFromDsn(dsn) { - // DSN: https://@/ - const u = new URL(dsn); - const projectId = u.pathname.replace(/^\//, ''); - const publicKey = u.username; - if (!projectId || !publicKey) throw new Error('DSN missing project id or public key'); - // GlitchTip authenticates the ingest by the `sentry_key` query param; when the - // SDK tunnels, that key lives inside the envelope header, which GlitchTip does - // not read here -> it would answer 403 "Denied". Pass the key explicitly. - const path = `/api/${projectId}/envelope/?sentry_key=${publicKey}&sentry_version=7`; - return { hostname: u.hostname, port: u.port || 443, path }; -} - -/* - * Forward the raw envelope to GlitchTip over IPv4 (`family: 4`). GlitchTip is - * behind Cloudflare, which is dual-stack; on IPv4-only hosts Node's fetch/ - * Happy-Eyeballs may pick the unroutable IPv6 and fail. Cloudflare always has - * IPv4, so pinning the family is safe everywhere and avoids that flakiness. - */ -function forwardToIngest(target, headers, body) { - return new Promise((resolve, reject) => { - const upstream = https.request({ - hostname: target.hostname, - port: target.port, - path: target.path, - method: 'POST', - family: 4, - headers - }, (r) => { - r.resume(); // drain the response - if (r.statusCode >= 400) { - console.error(`sentry-tunnel: upstream returned ${r.statusCode}`); - } - resolve(r.statusCode); - }); - upstream.on('error', reject); - upstream.end(body); - }); -} - -const publicDsn = Meteor.settings.public && Meteor.settings.public.sentryPublicDSN; - -if (publicDsn) { - let ingestTarget; - try { - ingestTarget = ingestTargetFromDsn(publicDsn); - } catch (e) { - console.error(`sentry-tunnel: invalid sentryPublicDSN, tunnel disabled: ${e.message}`); - } - - if (ingestTarget) { - WebApp.connectHandlers.use('/sentry-tunnel', (req, res) => { - if (req.method !== 'POST') { - res.writeHead(405, { Allow: 'POST' }); - res.end(); - return; - } - - const chunks = []; - let size = 0; - let aborted = false; - - req.on('data', (chunk) => { - if (aborted) return; - size += chunk.length; - if (size > MAX_BODY) { - aborted = true; - res.writeHead(413); - res.end(); - req.destroy(); - return; - } - chunks.push(chunk); - }); - - req.on('end', async () => { - if (aborted) return; - try { - const body = Buffer.concat(chunks); - const headers = { 'Content-Type': 'application/x-sentry-envelope' }; - if (req.headers['content-encoding']) { - headers['Content-Encoding'] = req.headers['content-encoding']; - } - // Preserve the reporter's IP so GlitchTip attributes the event correctly. - const fwd = req.headers['x-forwarded-for']; - headers['X-Forwarded-For'] = fwd - ? `${fwd}, ${req.socket.remoteAddress}` - : req.socket.remoteAddress; - - const status = await forwardToIngest(ingestTarget, headers, body); - res.writeHead(status); - res.end(); - } catch (err) { - // Never let a reporting failure surface to the user; just log it. - console.error(`sentry-tunnel: forward failed: ${err.message}`); - res.writeHead(502); - res.end(); - } - }); - }); - - console.log(`sentry-tunnel enabled -> https://${ingestTarget.hostname}${ingestTarget.path}`); - } -} diff --git a/imports/startup/server/sitemaps.js b/imports/startup/server/sitemaps.js index 70cf766..8026449 100644 --- a/imports/startup/server/sitemaps.js +++ b/imports/startup/server/sitemaps.js @@ -1,37 +1,44 @@ +/* global sitemaps Comments */ /* eslint-disable import/no-absolute-path */ -import { Meteor } from 'meteor/meteor'; -import { WebApp } from 'meteor/webapp'; -/* - * /sitemap.xml — own implementation replacing gadicohen:sitemaps (pre-0.9 - * package API, dead on Meteor 3). Static pages only: the per-fire section of - * the old handler was behind `firesMapEnabled = false` (dead code) and was - * dropped; see git history if it's ever wanted back. - */ +import Fires from '/imports/api/Fires/Fires'; +// import Comments from '/imports/api/Comments/Comments'; -const PAGES = [ - '/fires', - '/login', - '/signup', - '/recover-password', - '/credits', - '/terms', - '/license', - '/privacy', - '/about' -]; +const firesMapEnabled = false; -function xmlEscape(s) { - return s.replace(/&/g, '&').replace(//g, '>'); -} +sitemaps.add('/sitemap.xml', () => { + const today = new Date(); -WebApp.connectHandlers.use('/sitemap.xml', (req, res) => { - const lastmod = new Date().toISOString().slice(0, 10); - const urls = PAGES.map((page) => { - const loc = xmlEscape(Meteor.absoluteUrl(page.replace(/^\//, ''))); - return ` \n ${loc}\n ${lastmod}\n `; - }).join('\n'); - const body = `\n\n${urls}\n\n`; - res.writeHead(200, { 'Content-Type': 'application/xml; charset=utf-8' }); - res.end(body); + const out = []; + out.push({ page: '/fires', lastmod: today }); + out.push({ page: '/login', lastmod: today }); + out.push({ page: '/signup', lastmod: today }); + out.push({ page: '/recover-password', lastmod: today }); + out.push({ page: '/credits', lastmod: today }); + out.push({ page: '/terms', lastmod: today }); + out.push({ page: '/license', lastmod: today }); + out.push({ page: '/privacy', lastmod: today }); + out.push({ page: '/about', lastmod: today }); + + // When user has public page + /* const users = Meteor.users.find().fetch(); + _.each(users, function(user) { + out.push({ + page: '/persona/' + user.username, + lastmod: user.updatedAt + }); + }); */ + + if (firesMapEnabled) { + Fires.find({}, { limit: 100, sort: { createdAt: -1 } }).fetch().forEach((fire) => { + // Search the last comment of tha fire + const lastComment = Comments.getCollection().findOne({ referenceId: `fire-${fire._id}` }, { sort: { createdAt: -1 } }); + out.push({ + page: `/fire/archive/${fire._id._str}`, + lastmod: lastComment ? lastComment.createdAt : fire.updatedAt + }); + }); + } + + return out; }); diff --git a/imports/startup/server/subsUnion.js b/imports/startup/server/subsUnion.js index 8a2df27..2643359 100644 --- a/imports/startup/server/subsUnion.js +++ b/imports/startup/server/subsUnion.js @@ -1,53 +1,123 @@ /* eslint-disable import/no-absolute-path */ -// Wiring only: what the union actually does lives in subsUnionLogic.js, where it -// can be tested without booting a server (see test/server/subsUnion.test.js). - import { Meteor } from 'meteor/meteor'; import Subscriptions from '/imports/api/Subscriptions/Subscriptions'; import SiteSettings from '/imports/api/SiteSettings/SiteSettings'; -import calcUnionAsync from '/imports/startup/server/calcUnionAsync'; -import unionBounds from '/imports/startup/server/unionBounds'; -import createSubsUnion from '/imports/startup/server/subsUnionLogic'; -import unionTelemetry from '/imports/startup/server/unionTelemetry'; +import Perlin from 'loms.perlin'; +import './leaflet-workaround'; +import L from 'leaflet'; +import calcUnion from '/imports/ui/components/Maps/SubsUnion/Unify'; import { isMailServerMaster } from '/imports/startup/server/email'; -Meteor.startup(async () => { +// sudo apt-get install libcairo2-dev libjpeg-dev libgif-dev + +Meteor.startup(() => { if (!isMailServerMaster) { console.log('We only process subsUnion in master'); return; } + const debug = true; // Meteor.isDevelopment; + Perlin.seed(Math.random()); - const subsUnion = createSubsUnion({ - calcUnion: calcUnionAsync, - SiteSettings, - Subscriptions, - boundsOf: unionBounds, - telemetry: unionTelemetry - }); + const addNoisy = (osub) => { + const sub = osub; + let lat = Math.round(sub.location.lat * 10) / 10; + let lon = Math.round(sub.location.lon * 10) / 10; + const noiseBase = Perlin.perlin2(lat, lon); + const noise = Math.abs(noiseBase / 3); + lat += noise; + lon += noise; + sub.location.lat = lat; + sub.location.lon = lon; + sub.distance += noiseBase; + return sub; + }; - if (await subsUnion.isUnionOutdated()) { - console.log('Subs union outdated'); - subsUnion.scheduleRecreate(); - } else { - console.log('Subs union up-to-date'); + const noNoisy = sub => sub; + + const process = (isPublic) => { + const subscribers = Subscriptions.find().fetch(); + const result = calcUnion(L, subscribers, isPublic ? addNoisy : noNoisy, true); + const union = result[0]; + const bounds = result[1]; + + const publicl = isPublic ? 'public' : 'private'; + const Publicl = publicl.replace(/\b\w/g, l => l.toUpperCase()); + + if (typeof union === 'object') { + const unionSet = { + $set: { + name: `subs-${publicl}-union`, + value: JSON.stringify(union), + isPublic, + description: `${Publicl} subscriptions union`, + type: 'string' + } + }; + const boundsSet = { + $set: { + name: `subs-${publicl}-union-bounds`, + value: JSON.stringify(bounds), + isPublic, + description: `${Publicl} subscriptions union bounds`, + type: 'string' + } + }; + const sizeSet = { + $set: { + name: 'subs-union-count', + value: subscribers.length, + isPublic: false, + description: 'Subscriptions count', + type: 'number' + } + }; + // FIXME, take care of object size: + // https://stackoverflow.com/questions/10827812/what-is-the-length-maximum-for-a-string-data-type-in-mongodb-used-with-ruby + SiteSettings.upsert({ name: `subs-${publicl}-union` }, unionSet, { multi: false }); + SiteSettings.upsert({ name: `subs-${publicl}-union-bounds` }, boundsSet, { multi: false }); + SiteSettings.upsert({ name: 'subs-union-count' }, sizeSet, { multi: false }); + if (debug) console.log(`${Publicl} subscription union calculated`); + } else { + console.log('Subscription union failed!'); + } + }; + + // At startup, we check if it's necessary to calc subscriptions union again + const currentUnion = SiteSettings.findOne({ name: 'subs-public-union' }); + const lastSubs = Subscriptions.findOne({}, { sort: { updatedAt: -1 } }); + const countUnionSubs = SiteSettings.findOne({ name: 'subs-union-count' }); + const countSubs = Subscriptions.find({}).count(); + if (currentUnion && lastSubs) { + const lastUnionUpdated = currentUnion.updatedAt; + const lastSubsUpdated = lastSubs.updatedAt; + if (lastUnionUpdated > lastSubsUpdated || !countUnionSubs || countSubs !== countUnionSubs.value) { + console.log('Subs union outdated'); + process(true); + process(false); + } else { + console.log('Subs union up-to-date'); + } } - await Subscriptions.find({ createdAt: { $gt: new Date() } }).observeAsync({ - added: function newSubAdded(doc) { - console.log('Subs added, merging into union incrementally'); - subsUnion.enqueueAdd(doc); + Subscriptions.find({ createdAt: { $gt: new Date() } }).observe({ + added: function newSubAdded() { // doc) { + if (debug) console.log('Subs added so recreate union'); + process(true); + process(false); } }); - await Subscriptions.find().observeAsync({ - changed: function subsChanged() { - console.log('Subs changed so recreate union'); - subsUnion.scheduleRecreate(); + Subscriptions.find().observe({ + changed: function subsChanged() { // updatedDoc, oldDoc) { + if (debug) console.log('Subs changed so recreate union'); + process(true); + process(false); }, - removed: function subsRemoved() { - console.log('Subs removed so recreate union'); - subsUnion.scheduleRecreate(); + removed: function subsRemoved() { // oldDoc) { + if (debug) console.log('Subs removed so recreate union'); + process(true); + process(false); } }); }); diff --git a/imports/startup/server/subsUnionLogic.js b/imports/startup/server/subsUnionLogic.js deleted file mode 100644 index 61d0661..0000000 --- a/imports/startup/server/subsUnionLogic.js +++ /dev/null @@ -1,275 +0,0 @@ -/* eslint-disable import/no-absolute-path */ - -// All of the subscriptions-union behaviour, with its collaborators injected. -// -// This used to live inside the Meteor.startup() callback of subsUnion.js, where -// nothing was reachable from a test: the queue, the incremental fast path and the -// worker-failure handling could only be exercised by starting a whole server and -// hoping. That is how a failed worker call ended up being stored as a valid empty -// union (50ca9cc) and how a full recompute ended up blocking DDP (b4e5511 / -// fbb746f) — three production incidents in code with zero test coverage. -// subsUnion.js is now only the wiring; everything below is a plain function you -// can call with fakes. - -import Perlin from 'loms.perlin'; -import limitUnionSize, { MAX_UNION_BYTES } from './unionSizeGuard'; - -// Telegram-era subscriptions (and anything else half-written) can be missing the -// location or the radius. They are skipped, not an error: they predate the web -// subscriptions and there is nothing sensible to draw for them. -export const isValidSub = (osub, log = console) => { - const valid = !!(osub && osub.location && osub.location.lat && osub.location.lon && osub.distance); - if (!valid) log.info(`Wrong element to do union ${JSON.stringify(osub)}`); - return valid; -}; - -/** - * @param {object} deps - * @param {function} deps.calcUnion (subs, baseUnion) => Promise, off-thread - * @param {object} deps.SiteSettings Mongo collection holding the stored union - * @param {object} deps.Subscriptions Mongo collection of subscriptions - * @param {function} [deps.boundsOf] union => leaflet-style bounds (or null) - * @param {number} [deps.maxUnionBytes] - * @param {number} [deps.perlinSeed] fixed seed makes the public "noise" reproducible in tests - */ -const noTelemetry = { computed: () => {}, failed: () => {} }; - -const createSubsUnion = ({ - calcUnion, - SiteSettings, - Subscriptions, - boundsOf = () => null, - maxUnionBytes = MAX_UNION_BYTES, - perlinSeed = Math.random(), - debug = true, - log = console, - telemetry = noTelemetry -}) => { - Perlin.seed(perlinSeed); - - // Public unions are fuzzed so that the map does not disclose where exactly - // somebody subscribed. Returns a new object: callers hand us documents straight - // out of Mongo and the private pass must not see the fuzzed values. - const addNoisy = (sub) => { - const lat = Math.round(sub.location.lat * 10) / 10; - const lon = Math.round(sub.location.lon * 10) / 10; - const noiseBase = Perlin.perlin2(lat, lon); - const noise = Math.abs(noiseBase / 3); - return { - ...sub, - location: { ...sub.location, lat: lat + noise, lon: lon + noise }, - distance: sub.distance + noiseBase - }; - }; - - const noNoisy = sub => ({ ...sub, location: { ...sub.location } }); - - const decoratorFor = isPublic => (isPublic ? addNoisy : noNoisy); - - // Only called once calcUnion has actually resolved — union === null here means - // "zero valid subscriptions", a real result, not a failed computation. (A prior - // version guarded this with `typeof union === 'object'`, which is true for null - // too, so a failed worker call — caught upstream, leaving union as null — looked - // identical to a legitimate empty union and got silently stored as "null". - // Callers must catch calcUnion failures themselves and skip calling this.) - const storeUnion = async (isPublic, union, count) => { - const publicl = isPublic ? 'public' : 'private'; - const Publicl = publicl.replace(/\b\w/g, l => l.toUpperCase()); - // Mongo caps a document at 16 MiB and this value is also pushed to every - // browser: past a certain number of subscriptions the geometry has to be - // coarsened or the upsert simply starts failing. See unionSizeGuard.js. - const { union: safeUnion, json, degraded } = limitUnionSize(union, maxUnionBytes); - if (degraded) { - log.warn(`${Publicl} subscriptions union too big (${degraded.originalBytes} bytes), degraded via ${degraded.steps.join(' -> ')}`); - } - const bounds = boundsOf(safeUnion); - const unionSet = { - $set: { - name: `subs-${publicl}-union`, - value: json, - isPublic, - description: `${Publicl} subscriptions union`, - type: 'string', - degraded: degraded ? degraded.steps.join(' -> ') : null - } - }; - const boundsSet = { - $set: { - name: `subs-${publicl}-union-bounds`, - value: JSON.stringify(bounds), - isPublic, - description: `${Publicl} subscriptions union bounds`, - type: 'string' - } - }; - const sizeSet = { - $set: { - name: 'subs-union-count', - value: count, - isPublic: false, - description: 'Subscriptions count', - type: 'number' - } - }; - await SiteSettings.upsertAsync({ name: `subs-${publicl}-union` }, unionSet, { multi: false }); - await SiteSettings.upsertAsync({ name: `subs-${publicl}-union-bounds` }, boundsSet, { multi: false }); - await SiteSettings.upsertAsync({ name: 'subs-union-count' }, sizeSet, { multi: false }); - if (debug) log.log(`${Publicl} subscription union calculated`); - return { degraded, bytes: Buffer.byteLength(json, 'utf8') }; - }; - - const recomputeOne = async (isPublic) => { - const subscribers = await Subscriptions.find().fetchAsync(); - const decorated = subscribers.filter(s => isValidSub(s, log)).map(decoratorFor(isPublic)); - - const started = Date.now(); - try { - const union = await calcUnion(decorated); - const { degraded, bytes } = await storeUnion(isPublic, union, subscribers.length); - telemetry.computed({ - kind: 'recreate', - isPublic, - subs: decorated.length, - ms: Date.now() - started, - bytes, - degraded: degraded ? degraded.steps.join(' -> ') : null - }); - } catch (e) { - log.error('subsUnion worker failed, union left unchanged', e); - telemetry.failed(e, { kind: 'recreate', isPublic, subs: decorated.length, ms: Date.now() - started }); - } - }; - - const recreate = async () => { await recomputeOne(true); await recomputeOne(false); }; - - // Single serialized worker over two kinds of work: incremental adds (a real - // FIFO queue — each must see the previous one's result, none get dropped) and - // full recomputes (coalesced — a burst of changed/removed events only needs one - // pass once things settle). Both touch the same stored union, so they share this - // one lock rather than racing each other. - let busy = false; - const addQueue = []; - let recomputePending = false; - let idleWaiters = []; - - const flushIdleWaiters = () => { - const waiters = idleWaiters; - idleWaiters = []; - waiters.forEach(resolve => resolve()); - }; - - // Fast path for the common case (a new subscription): merge just the new circle - // into the union already stored instead of redoing the full turf.union chain - // over every subscription again (that chain is what made a single recompute take - // ~20+ minutes with thousands of subscriptions). Falls back to a full recreate() - // if the stored state isn't exactly "one behind" what we expect — safer than - // merging into stale/inconsistent data. Sets recomputePending directly (rather - // than calling a schedule helper) since runLoop already re-checks it right after - // this returns. - const incrementalAdd = async (doc) => { - if (!isValidSub(doc, log)) return; - - const countSetting = await SiteSettings.findOneAsync({ name: 'subs-union-count' }); - const countSubs = await Subscriptions.find({}).countAsync(); - if (!countSetting || countSetting.value !== countSubs - 1) { - if (debug) log.log('Subs union state not exactly one behind, doing a full recreate instead'); - recomputePending = true; - return; - } - - // eslint-disable-next-line no-restricted-syntax - for (const isPublic of [true, false]) { - const decorated = decoratorFor(isPublic)(doc); - const publicl = isPublic ? 'public' : 'private'; - // eslint-disable-next-line no-await-in-loop - const current = await SiteSettings.findOneAsync({ name: `subs-${publicl}-union` }); - const baseUnion = current && current.value ? JSON.parse(current.value) : null; - - const started = Date.now(); - try { - // eslint-disable-next-line no-await-in-loop - const union = await calcUnion([decorated], baseUnion); - // eslint-disable-next-line no-await-in-loop - const { degraded, bytes } = await storeUnion(isPublic, union, countSubs); - telemetry.computed({ - kind: 'incrementalAdd', - isPublic, - subs: countSubs, - ms: Date.now() - started, - bytes, - degraded: degraded ? degraded.steps.join(' -> ') : null - }); - } catch (e) { - log.error('subsUnion incremental worker failed, falling back to a full recreate', e); - telemetry.failed(e, { kind: 'incrementalAdd', isPublic, subs: countSubs, ms: Date.now() - started }); - recomputePending = true; - return; - } - } - if (debug) log.log('Subs union updated incrementally'); - }; - - const runLoop = () => { - if (busy) return; - if (addQueue.length > 0) { - busy = true; - const doc = addQueue.shift(); - incrementalAdd(doc) - .catch(e => log.error('subsUnion incremental add failed', e)) - .finally(() => { busy = false; runLoop(); }); - return; - } - if (recomputePending) { - recomputePending = false; - busy = true; - recreate() - .catch(e => log.error('subsUnion recompute failed', e)) - .finally(() => { busy = false; runLoop(); }); - return; - } - flushIdleWaiters(); - }; - - const enqueueAdd = (doc) => { addQueue.push(doc); runLoop(); }; - - // Fire-and-forget on purpose: both recreate() and incrementalAdd() can take a - // while, and callers (Meteor.startup, observeAsync callbacks) must not block on it. - const scheduleRecreate = () => { recomputePending = true; runLoop(); }; - - // Resolves when the queue has drained and nothing is running. Only meant for - // tests and shutdown — production code must never await the union. - const idle = () => new Promise((resolve) => { - if (!busy && addQueue.length === 0 && !recomputePending) { - resolve(); - return; - } - idleWaiters.push(resolve); - }); - - // At startup we check whether the stored union still matches the subscriptions. - const isUnionOutdated = async () => { - const currentUnion = await SiteSettings.findOneAsync({ name: 'subs-public-union' }); - const lastSubs = await Subscriptions.findOneAsync({}, { sort: { updatedAt: -1 } }); - if (!currentUnion || !lastSubs) return false; - const countUnionSubs = await SiteSettings.findOneAsync({ name: 'subs-union-count' }); - const countSubs = await Subscriptions.find({}).countAsync(); - return currentUnion.updatedAt > lastSubs.updatedAt - || !countUnionSubs - || countSubs !== countUnionSubs.value; - }; - - return { - addNoisy, - noNoisy, - storeUnion, - recreate, - incrementalAdd, - enqueueAdd, - scheduleRecreate, - isUnionOutdated, - idle, - stats: () => ({ busy, queued: addQueue.length, recomputePending }) - }; -}; - -export default createSubsUnion; diff --git a/imports/startup/server/unionBounds.js b/imports/startup/server/unionBounds.js deleted file mode 100644 index 47e39cb..0000000 --- a/imports/startup/server/unionBounds.js +++ /dev/null @@ -1,11 +0,0 @@ -/* eslint-disable import/no-absolute-path */ - -// Leaflet is a browser library; the workaround import fakes the bits of `window` -// it touches at load time so it can also run on the server. Kept apart from -// subsUnionLogic so that module (and its tests) stay free of that dependency. -import './leaflet-workaround'; -import L from 'leaflet'; - -const unionBounds = union => (union === null ? null : L.geoJSON(union).getBounds()); - -export default unionBounds; diff --git a/imports/startup/server/unionSizeGuard.js b/imports/startup/server/unionSizeGuard.js deleted file mode 100644 index 07cdb3c..0000000 --- a/imports/startup/server/unionSizeGuard.js +++ /dev/null @@ -1,212 +0,0 @@ -// Keeps the stored subscriptions-union GeoJSON below a size that Mongo (and the -// DDP connection that ships it to every client) can actually take. -// -// The union of every subscription circle is stored as a JSON string inside a -// single `siteSettings` document. A BSON document is capped at 16 MiB, so past -// some number of subscriptions the upsert would simply start failing and the map -// would silently freeze at the last union that fit — which is exactly the kind of -// scale bug nobody notices until production. On top of the hard Mongo limit, that -// same string is published to browsers, so multi-megabyte unions are already a -// problem well before 16 MiB. -// -// The chosen fix is progressive degradation instead of failure: the geometry is -// coarsened, in visually-cheapest-first order, until it fits. The zone union is a -// decorative overlay ("roughly where people are watching"), never a source of -// truth for alerts, so losing precision is acceptable; losing the whole overlay is -// not. Every degradation is reported back so the caller can store it alongside the -// union and ops can see it happening. -// -// The ladder, in order: -// 1. coordinate precision 6 -> 5 -> 4 -> 3 decimals (~0.1 m -> ~100 m), dropping -// points that collapse onto their neighbour after rounding. -// 2. point decimation, keeping every k-th vertex of each ring (circles are -// generated with 144 steps, so there is a lot of slack here). -// 3. dropping holes (inner rings) — visually the least missed. -// 4. dropping the smallest polygons of the MultiPolygon until it fits. -// If even a single polygon does not fit, null is returned rather than storing -// something Mongo will reject. - -// 16 MiB is Mongo's hard limit for the whole document; this cap is deliberately -// well below it because the same value travels over DDP to every connected -// browser. Raise it only together with a plan for the client side. -export const MAX_UNION_BYTES = 8 * 1024 * 1024; - -const MIN_RING_POINTS = 12; -const DECIMATION_FACTORS = [2, 3, 4, 6, 8]; -const PRECISIONS = [5, 4, 3]; - -export const byteLength = json => Buffer.byteLength(json, 'utf8'); - -const geometryOf = union => (union && union.type === 'Feature' ? union.geometry : union); - -// Both Polygon and MultiPolygon, seen as a list of polygons (a polygon being a -// list of rings, the first one the outer one). -const polygonsOf = (geometry) => { - if (!geometry || !geometry.coordinates) return []; - return geometry.type === 'MultiPolygon' ? geometry.coordinates : [geometry.coordinates]; -}; - -const withPolygons = (union, polygons) => { - const geometry = geometryOf(union); - const coordinates = geometry.type === 'MultiPolygon' ? polygons : polygons[0]; - const newGeometry = { ...geometry, coordinates }; - return union.type === 'Feature' ? { ...union, geometry: newGeometry } : newGeometry; -}; - -// A ring is only a ring if it closes and has at least three distinct corners. -const closeRing = (ring) => { - if (ring.length < 3) return null; - const [firstLon, firstLat] = ring[0]; - const [lastLon, lastLat] = ring[ring.length - 1]; - const closed = firstLon === lastLon && firstLat === lastLat ? ring : [...ring, [firstLon, firstLat]]; - return closed.length >= 4 ? closed : null; -}; - -const roundRing = (ring, factor) => { - const out = []; - for (let i = 0; i < ring.length; i += 1) { - const lon = Math.round(ring[i][0] * factor) / factor; - const lat = Math.round(ring[i][1] * factor) / factor; - const prev = out[out.length - 1]; - if (!prev || prev[0] !== lon || prev[1] !== lat) out.push([lon, lat]); - } - return closeRing(out); -}; - -const decimateRing = (ring, keepEvery) => { - if (ring.length <= MIN_RING_POINTS) return ring; - const out = []; - for (let i = 0; i < ring.length - 1; i += keepEvery) out.push(ring[i]); - return closeRing(out) || ring; -}; - -// Rings that survive a transformation keep the polygon alive; a polygon whose -// outer ring collapsed is dropped entirely (its holes are meaningless then). -const mapPolygons = (polygons, mapRing) => polygons - .map(rings => rings.map(mapRing).filter(Boolean)) - .filter(rings => rings.length > 0); - -const ringBboxArea = (ring) => { - let minLon = Infinity; - let minLat = Infinity; - let maxLon = -Infinity; - let maxLat = -Infinity; - for (let i = 0; i < ring.length; i += 1) { - if (ring[i][0] < minLon) [minLon] = ring[i]; - if (ring[i][0] > maxLon) [maxLon] = ring[i]; - if (ring[i][1] < minLat) [, minLat] = ring[i]; - if (ring[i][1] > maxLat) [, maxLat] = ring[i]; - } - return (maxLon - minLon) * (maxLat - minLat); -}; - -/** - * Returns the largest-fidelity version of `union` whose JSON fits in `maxBytes`, - * together with that JSON (callers always need the string anyway, and - * re-stringifying a multi-megabyte object on the main thread is not free). - * - * @returns {{ union: object|null, json: string, bytes: number, - * degraded: null | { originalBytes: number, steps: string[] } }} - */ -const limitUnionSize = (union, maxBytes = MAX_UNION_BYTES) => { - let json = JSON.stringify(union === undefined ? null : union); - let bytes = byteLength(json); - if (union === null || union === undefined || bytes <= maxBytes) { - return { - union: union === undefined ? null : union, json, bytes, degraded: null - }; - } - - const originalBytes = bytes; - const steps = []; - let current = union; - - const attempt = (step, transform) => { - const next = transform(); - if (!next) return false; - current = next; - steps.push(step); - json = JSON.stringify(current); - bytes = byteLength(json); - return bytes <= maxBytes; - }; - - const done = () => ({ - union: current, json, bytes, degraded: { originalBytes, steps } - }); - - for (let i = 0; i < PRECISIONS.length; i += 1) { - const precision = PRECISIONS[i]; - const factor = 10 ** precision; - const fits = attempt(`precision:${precision}`, () => { - const polygons = mapPolygons(polygonsOf(geometryOf(current)), ring => roundRing(ring, factor)); - return polygons.length > 0 ? withPolygons(current, polygons) : null; - }); - if (fits) return done(); - } - - for (let i = 0; i < DECIMATION_FACTORS.length; i += 1) { - const keepEvery = DECIMATION_FACTORS[i]; - const fits = attempt(`decimate:${keepEvery}`, () => { - const polygons = mapPolygons(polygonsOf(geometryOf(union)), ring => decimateRing( - roundRing(ring, 10 ** PRECISIONS[PRECISIONS.length - 1]) || ring, - keepEvery - )); - return polygons.length > 0 ? withPolygons(current, polygons) : null; - }); - if (fits) return done(); - } - - const fitsWithoutHoles = attempt('holes', () => { - const polygons = polygonsOf(geometryOf(current)).map(rings => [rings[0]]); - return polygons.length > 0 ? withPolygons(current, polygons) : null; - }); - if (fitsWithoutHoles) return done(); - - // Last resort: drop the smallest polygons first, so what remains is the part of - // the map a user is most likely to be looking at. Binary search for how many - // survive — trying one fewer at a time means re-serializing a multi-megabyte - // object hundreds of times, which is precisely the kind of main-thread stall - // this whole area is trying to get rid of. - const ordered = polygonsOf(geometryOf(current)) - .map(rings => ({ rings, area: ringBboxArea(rings[0]) })) - .sort((a, b) => b.area - a.area) - .map(p => p.rings); - - const fitsWith = (keep) => { - const candidate = withPolygons(current, ordered.slice(0, keep)); - const candidateJson = JSON.stringify(candidate); - return byteLength(candidateJson) <= maxBytes ? { candidate, candidateJson } : null; - }; - - let low = 1; - let high = ordered.length - 1; - let best = null; - while (low <= high) { - const mid = Math.floor((low + high) / 2); - const fit = fitsWith(mid); - if (fit) { - best = { keep: mid, ...fit }; - low = mid + 1; - } else { - high = mid - 1; - } - } - if (best) { - current = best.candidate; - json = best.candidateJson; - bytes = byteLength(json); - steps.push(`polygons:${best.keep}`); - return done(); - } - - // A single polygon that still does not fit means something is very wrong - // upstream; storing nothing beats an upsert that Mongo rejects on every run. - steps.push('dropped'); - current = null; - json = 'null'; - bytes = byteLength(json); - return done(); -}; - -export default limitUnionSize; diff --git a/imports/startup/server/unionTelemetry.js b/imports/startup/server/unionTelemetry.js deleted file mode 100644 index 1209440..0000000 --- a/imports/startup/server/unionTelemetry.js +++ /dev/null @@ -1,51 +0,0 @@ -/* eslint-disable import/no-absolute-path */ - -// Telemetría de la unión de suscripciones (fase 12). -// -// Hasta ahora la única señal de que la unión iba mal era que el mapa se quedaba -// viejo: los fallos del worker se registraban con console.error y ahí morían. Lo -// que se manda a GlitchTip es deliberadamente poco: un aviso cuando una unión -// tarda más de lo que el banco de carga considera sano, otro cuando hay que -// degradar la geometría por tamaño, y los fallos. Las uniones normales solo -// dejan una línea de log. - -import * as Sentry from '@sentry/node'; -import { Meteor } from 'meteor/meteor'; -import ravenLogger from '/imports/startup/server/ravenLogger'; - -// Medido en bench/union-bench.js: 10.000 suscripciones repartidas por España son -// ~40 s. Pasar del minuto significa o mucha más gente, o una distribución que no -// se solapa (el caso caro), o algo que se ha torcido. En cualquiera de los tres -// casos queremos enterarnos. -const DEFAULT_SLOW_MS = 60 * 1000; - -const slowMs = () => ( - (Meteor.settings.private && Meteor.settings.private.unionSlowMs) || DEFAULT_SLOW_MS -); - -const unionTelemetry = { - computed({ kind, isPublic, subs, ms, bytes, degraded }) { - const scope = isPublic ? 'public' : 'private'; - const line = `subsUnion ${kind} ${scope}: ${subs} subs, ${Math.round(ms)} ms, ${bytes} bytes${degraded ? ` (degradada: ${degraded})` : ''}`; - console.log(line); - - if (ms > slowMs()) { - Sentry.captureMessage(`subsUnion lenta: ${Math.round(ms / 1000)} s con ${subs} suscripciones`, { - level: 'warning', - extra: { kind, scope, subs, ms, bytes } - }); - } - if (degraded) { - Sentry.captureMessage(`subsUnion degradada por tamaño: ${degraded}`, { - level: 'warning', - extra: { kind, scope, subs, ms, bytes, degraded } - }); - } - }, - - failed(error, context) { - ravenLogger.log(error, context); - } -}; - -export default unionTelemetry; diff --git a/imports/ui/components/AccountPageFooter/AccountPageFooter.scss b/imports/ui/components/AccountPageFooter/AccountPageFooter.scss index 4088d82..0e6b0df 100644 --- a/imports/ui/components/AccountPageFooter/AccountPageFooter.scss +++ b/imports/ui/components/AccountPageFooter/AccountPageFooter.scss @@ -1,4 +1,4 @@ -@use '../../stylesheets/colors' as *; +@import '../../stylesheets/colors'; .AccountPageFooter { margin: 25px 0 0; diff --git a/imports/ui/components/Authenticated/Authenticated.js b/imports/ui/components/Authenticated/Authenticated.js index 80b0efe..f34da2f 100644 --- a/imports/ui/components/Authenticated/Authenticated.js +++ b/imports/ui/components/Authenticated/Authenticated.js @@ -1,16 +1,23 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Navigate } from 'react-router-dom'; +import { Route, Redirect } from 'react-router-dom'; -// v6 route guard: rendered as a route `element` wrapping the real page. -// Renders its children when authenticated, otherwise redirects to /login. -const Authenticated = ({ authenticated, children }) => ( - authenticated ? children : +const Authenticated = ({ loggingIn, authenticated, component, path, exact, ...rest }) => ( + ( + authenticated ? + (React.createElement(component, { ...props, ...rest, loggingIn, authenticated })) : + () + )} + /> ); Authenticated.propTypes = { + loggingIn: PropTypes.bool.isRequired, authenticated: PropTypes.bool.isRequired, - children: PropTypes.node.isRequired + component: PropTypes.func.isRequired, }; export default Authenticated; diff --git a/imports/ui/components/AuthenticatedNavigation/AuthenticatedNavigation.js b/imports/ui/components/AuthenticatedNavigation/AuthenticatedNavigation.js index eb102b9..e479f8f 100644 --- a/imports/ui/components/AuthenticatedNavigation/AuthenticatedNavigation.js +++ b/imports/ui/components/AuthenticatedNavigation/AuthenticatedNavigation.js @@ -1,10 +1,10 @@ /* eslint-disable import/no-absolute-path */ import React from 'react'; import PropTypes from 'prop-types'; +import { withRouter } from 'react-router-dom'; import { LinkContainer } from 'react-router-bootstrap'; -import withRouterCompat from '../withRouterCompat/withRouterCompat'; /* import { Nav, NavDropdown } from 'react-bootstrap'; */ -import { withTranslation, Trans } from 'react-i18next'; +import { translate, Trans } from 'react-i18next'; import { testId } from '/imports/ui/components/Utils/TestUtils'; /* @@ -39,4 +39,4 @@ AuthenticatedNavigation.propTypes = { name: PropTypes.string.isRequired }; -export default withTranslation()(withRouterCompat(AuthenticatedNavigation)); +export default translate([], { wait: true })(withRouter(AuthenticatedNavigation)); diff --git a/imports/ui/components/BetaRibbon/BetaRibbon.js b/imports/ui/components/BetaRibbon/BetaRibbon.js index c00e853..584f38d 100644 --- a/imports/ui/components/BetaRibbon/BetaRibbon.js +++ b/imports/ui/components/BetaRibbon/BetaRibbon.js @@ -4,7 +4,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { withTranslation } from 'react-i18next'; +import { translate } from 'react-i18next'; import './BetaRibbon.scss'; @@ -30,4 +30,4 @@ BetaRibbon.propTypes = { BetaRibbon.defaultProps = { }; -export default withTranslation()(BetaRibbon); +export default translate([], { wait: true })(BetaRibbon); diff --git a/imports/ui/components/CenterInMyPosition/CenterInMyPosition.js b/imports/ui/components/CenterInMyPosition/CenterInMyPosition.js index 574da35..6fde995 100644 --- a/imports/ui/components/CenterInMyPosition/CenterInMyPosition.js +++ b/imports/ui/components/CenterInMyPosition/CenterInMyPosition.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import { Tracker } from 'meteor/tracker'; import { ReactiveVar } from 'meteor/reactive-var'; import { Button } from 'react-bootstrap'; -import { withTranslation } from 'react-i18next'; +import { translate } from 'react-i18next'; import { Bert } from 'meteor/themeteorchef:bert'; import './CenterInMyPosition.scss'; @@ -48,7 +48,7 @@ class CenterInMyPosition extends React.Component { const { onlyIcon, t } = this.props; const msg = t('Centrar en tu ubicación'); return ( - ); } @@ -63,4 +63,4 @@ CenterInMyPosition.propTypes = { onlyIcon: PropTypes.bool }; -export default withTranslation()(CenterInMyPosition); +export default translate([], { wait: true })(CenterInMyPosition); diff --git a/imports/ui/components/Col/Col.js b/imports/ui/components/Col/Col.js index affc10d..2a20586 100644 --- a/imports/ui/components/Col/Col.js +++ b/imports/ui/components/Col/Col.js @@ -1,6 +1,111 @@ -// The old custom Col hand-rolled Bootstrap-4/5 grid classes on top of -// react-bootstrap 0.31 internals (`bootstrapUtils`, `StyleConfig`), which no -// longer exist in react-bootstrap v2. v2's own Col produces the exact same -// `col`/`col-{size}-{n}` output for the xs/sm/md/lg/xl props we use, so we -// simply re-export it and keep every importer unchanged. -export { Col as default } from 'react-bootstrap'; +import classNames from 'classnames'; +import React from 'react'; +import PropTypes from 'prop-types'; +import elementType from 'prop-types-extra/lib/elementType'; + +import { bsClass, prefix, splitBsProps } from 'react-bootstrap/lib/utils/bootstrapUtils'; +import { DEVICE_SIZES } from 'react-bootstrap/lib/utils/StyleConfig'; + +const column = PropTypes.oneOfType([ + PropTypes.oneOf(['auto']), + PropTypes.number, +]); + +const propTypes = { + componentClass: elementType, + + /** + * The number of columns you wish to span + * + * for Extra small devices Phones (<576px) + * + * class-prefix `col-` + */ + xs: column, + + /** + * The number of columns you wish to span + * + * for Small devices Tablets (≥576px) + * + * class-prefix `col-sm-` + */ + sm: column, + + /** + * The number of columns you wish to span + * + * for Medium devices Desktops (≥768px) + * + * class-prefix `col-md-` + */ + md: column, + + /** + * The number of columns you wish to span + * + * for Large devices Desktops (≥992px) + * + * class-prefix `col-lg-` + */ + lg: column, + + /** + * The number of columns you wish to span + * + * for Large devices Desktops (≥1200px) + * + * class-prefix `col-xl-` + */ + xl: column, +}; + +const defaultProps = { + componentClass: 'div', +}; + +class Col extends React.Component { + render() { + const { componentClass: Component, className, ...props } = this.props; + const [bsProps, elementProps] = splitBsProps(props); + + const classes = []; + + DEVICE_SIZES.forEach((size) => { + const propValue = elementProps[size]; + + if (propValue == null) return; + + if (size === 'xs') { + // col and col-4 + classes.push(propValue === true ? + bsProps.bsClass : + prefix(bsProps, `${propValue}`), + ); + } else { + // col-md-3 + classes.push( + prefix(bsProps, `${size}-${propValue}`), + ); + } + + delete elementProps[size]; + }); + + if (!classes.length) { + classes.push(bsProps.bsClass); // plain 'col' + } + + return ( + + ); + } +} + +Col.propTypes = propTypes; +Col.defaultProps = defaultProps; + +export default bsClass('col', Col); diff --git a/imports/ui/components/Comments/Comments.scss b/imports/ui/components/Comments/Comments.scss deleted file mode 100644 index c84305b..0000000 --- a/imports/ui/components/Comments/Comments.scss +++ /dev/null @@ -1,76 +0,0 @@ -.comments-section { - width: 100%; -} - -.comments-box { - max-width: inherit; - padding: 0; -} - -.comments-list { - list-style: none; - padding: 0; - margin: 0; -} - -.comment { - padding: 0.75em 0; - border-bottom: 1px solid rgba(0, 0, 0, 0.08); -} - -.comment-header { - display: flex; - justify-content: space-between; - font-size: 0.9em; -} - -.comment-author { - font-weight: bold; -} - -.comment-date { - color: #888; -} - -p.comment-content { - white-space: pre-line; - margin: 0.4em 0; -} - -.comment-media-image { - max-width: 100%; -} - -.comment-media-youtube iframe { - width: 100%; - min-height: 240px; - border: 0; -} - -.comment-actions .btn { - padding-left: 0; - padding-right: 0.75em; -} - -.comment-actions .btn.active { - font-weight: bold; -} - -/* keep the previous "remove" button neutral (not danger red) */ -.comment-owner-actions .remove-action { - color: rgb(51, 51, 51); -} - -.comment-form .create-comment { - height: 9em; - margin-bottom: 0.5em; -} - -.comment-login-hint { - color: #888; - font-style: italic; -} - -.img-avatar { - margin-right: 5px; -} diff --git a/imports/ui/components/Comments/CommentsBox.js b/imports/ui/components/Comments/CommentsBox.js deleted file mode 100644 index 9008351..0000000 --- a/imports/ui/components/Comments/CommentsBox.js +++ /dev/null @@ -1,152 +0,0 @@ -/* eslint-disable import/no-absolute-path */ -/* eslint-disable jsx-a11y/media-has-caption */ -import React from 'react'; -import PropTypes from 'prop-types'; -import { withTracker } from 'meteor/react-meteor-data'; -import { withTranslation } from 'react-i18next'; -import { Meteor } from 'meteor/meteor'; -import moment from 'moment'; -import CommentsCollection from '/imports/api/Comments/Comments'; -import './Comments.scss'; - -function MediaEmbed({ media }) { - if (!media || !media.content) return null; - if (media.type === 'image') { - return ; - } - if (media.type === 'youtube') { - return ( -
-