tcef-notifications/src/index.ts
vjrj a1ed206985 Fase 1b: cableado del matcher (ingesta polling, runner shadow/full, comparador)
- config: bloque matcher (MATCHER_MODE off/shadow/full, IRON_PASSWORD, audiencia,
  poll, batch) + validacion
- db: colecciones subscriptions/activefires/notifications_shadow/matcher_state +
  ensureMatcherIndexes (2dsphere en notifications_shadow para el dedupe 500m)
- matcher/context: MatchContext sobre Mongo real (candidatas geo $near 1000km +
  audiencia; dedupe geo $near 500m; lang del owner)
- matcher/ingest: polling de activefires por createdAt>checkpoint (Mongo 3.2 sin
  change streams), checkpoint persistido en matcher_state
- matcher/runner: loop shadow(->notifications_shadow)/full(->notifications),
  idempotente por dedupe+checkpoint
- matcher/compare: comparador de shadow (shadowOnly=spam, realOnly=faltantes,
  contentMismatches) — el gate antes del cutover
- index: arranca el matcher; fake-repo ampliado (upsert, sort, $gt/$lt)
- docs/env/README actualizados

73 tests en verde, typecheck y build OK.
2026-07-13 18:07:06 +02:00

80 lines
3 KiB
TypeScript

import { Worker } from 'bullmq';
import { loadConfig } from './config.js';
import { createLogger } from './logger.js';
import { connect, ensureIndexes, ensureMatcherIndexes } from './db.js';
import { createMatcherRunner } from './matcher/runner.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();
// Fase 1b — geospatial matcher (fire → subscriptions → notifications).
if (cfg.matcher.mode !== 'off') await ensureMatcherIndexes(repos);
const matcher = createMatcherRunner(cfg, repos, log);
matcher.start();
const shutdown = async (signal: string): Promise<void> => {
log.info({ signal }, 'shutting down');
poller.stop();
matcher.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);
});