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

57
src/types.ts Normal file
View file

@ -0,0 +1,57 @@
import type { ObjectId } from 'mongodb';
/** A `notifications` document, as written by node-red (see docs/legacy-behavior.md). */
export interface NotificationDoc {
_id: ObjectId;
userId: string;
subsId?: ObjectId;
content: string;
geo: { type: 'Point'; coordinates: [number, number] }; // [lng, lat]
type: 'mobile' | 'web';
notified?: boolean;
notifiedAt?: Date;
emailNotified?: boolean;
emailNotifiedAt?: Date;
when: Date;
sealed: string;
createdAt?: Date;
updatedAt?: Date;
// provenance set by our poller (invisible to the old system)
claimedBy?: string;
claimedAt?: Date;
}
export interface UserDoc {
_id: string;
lang?: string;
fireBaseToken?: string | null;
fireBaseTokenDeadAt?: Date;
profile?: { name?: { first?: string } };
emails?: Array<{ address: string; verified?: boolean }>;
services?: { password?: { bcrypt?: string } };
}
export type Channel = 'fcm' | 'email';
/** Maps a notification `type` to a channel. */
export function channelOfType(type: NotificationDoc['type']): Channel {
return type === 'mobile' ? 'fcm' : 'email';
}
/** Record in `notification_sends` — persistent idempotency (unique on notificationId+channel). */
export interface SendRecord {
notificationId: ObjectId;
channel: Channel;
status: 'sending' | 'sent' | 'skipped' | 'failed' | 'dry-run' | 'dead-token' | 'no-recipient';
mode: OperatingMode;
detail?: string;
createdAt: Date;
}
export type OperatingMode = 'dry-run' | 'shadow' | 'canary' | 'full';
/** A queued send job (payload persisted in Redis by BullMQ). */
export interface SendJob {
notificationId: string; // ObjectId hex
channel: Channel;
}