Fase 1a: microservicio tcef-notifications (workers FCM v1 + email)

Sustituye el envío de push por la API legacy de GCM (muerta desde jun-2024) y
saca el envío del observer de Meteor a una cola BullMQ controlada.

- poller: consume 'notifications' pendientes (campos correctos; el cron viejo
  tenía un typo nofitied/emailNofitied que nunca casaba)
- fcm-worker: firebase-admin (FCM HTTP v1), payload compatible con la app
  Flutter (FLUTTER_NOTIFICATION_CLICK, collapseKey=_id, data); purga tokens
  muertos sin reintentar
- email-worker: nodemailer + plantillas new-fire portadas verbatim
- idempotencia persistente: notification_sends, indice unico (notificationId,
  channel) -> sobrevive reinicios y replays
- salvaguardas anti-spam: modos dry-run/shadow/canary/full, exclusion mutua por
  canal (OWNED_CHANNELS + notifDisabledChannels en Meteor), kill switch,
  limites por usuario/dia y circuit breaker por batch
- docs/legacy-behavior.md (caracterizacion) + docs/rollout.md
- 47 tests (vitest + mongodb-memory-server), typecheck y build OK
- deploy: systemd + pm2 (Node 22 standalone)

Falta el service account de Firebase para envio real de push (pedir al usuario).
This commit is contained in:
vjrj 2026-07-13 00:10:29 +02:00
commit d20e168c90
39 changed files with 8378 additions and 0 deletions

73
src/index.ts Normal file
View file

@ -0,0 +1,73 @@
import { Worker } from 'bullmq';
import { loadConfig } from './config.js';
import { createLogger } from './logger.js';
import { connect, ensureIndexes } from './db.js';
import { createQueues, redisConnection, QUEUE_NAMES } from './queue.js';
import { createFcmClient, type FcmClient } from './fcm.js';
import { createMailer, type Mailer } from './mailer.js';
import { createPoller } from './poller.js';
import { processEmailJob, processFcmJob, type Deps } from './handlers.js';
import type { Channel, SendJob } from './types.js';
async function main(): Promise<void> {
const cfg = loadConfig();
const log = createLogger(cfg.logLevel, cfg.serviceName);
log.info(
{ mode: cfg.mode, owned: cfg.ownedChannels, killSwitch: cfg.killSwitch, db: cfg.dbName },
'tcef-notifications starting',
);
if (cfg.killSwitch) log.warn('KILL_SWITCH is ON — no sends will happen');
const repos = await connect(cfg);
await ensureIndexes(repos);
const queues = createQueues(cfg);
const connection = redisConnection(cfg);
// Real senders are only constructed when the mode may actually send, and only
// for owned channels — so dry-run/shadow never even needs the credentials.
const willSend = cfg.mode === 'canary' || cfg.mode === 'full';
let fcm: FcmClient | undefined;
let mailer: Mailer | undefined;
if (willSend && cfg.ownedChannels.includes('fcm')) fcm = createFcmClient(cfg);
if (willSend && cfg.ownedChannels.includes('email')) mailer = createMailer(cfg);
const deps: Deps = { cfg, repos, log, fcm, mailer };
const workers: Worker<SendJob>[] = [];
const handlerFor: Record<Channel, (id: string) => Promise<void>> = {
fcm: (id) => processFcmJob(deps, id),
email: (id) => processEmailJob(deps, id),
};
for (const channel of cfg.ownedChannels) {
const worker = new Worker<SendJob>(
QUEUE_NAMES[channel],
async (job) => handlerFor[channel](job.data.notificationId),
{ connection, concurrency: channel === 'email' ? 5 : 20 },
);
worker.on('failed', (job, err) => {
log.error({ channel, jobId: job?.id, err: err.message }, 'worker: job failed (dead-letter if attempts exhausted)');
});
workers.push(worker);
}
const poller = createPoller(cfg, repos, queues, log);
poller.start();
const shutdown = async (signal: string): Promise<void> => {
log.info({ signal }, 'shutting down');
poller.stop();
await Promise.all(workers.map((w) => w.close()));
await Promise.all(Object.values(queues).map((q) => q.close()));
await repos.close();
process.exit(0);
};
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
}
main().catch((err) => {
console.error('fatal:', err);
process.exit(1);
});