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:
commit
d20e168c90
39 changed files with 8378 additions and 0 deletions
73
src/index.ts
Normal file
73
src/index.ts
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue