Messenger/packages/backend/migrations/1720000000006_notifications.cjs
Заид Омар Медхат | Zaid Omar Medhat 47fbf861ee Phase 6.5: notifications service + in-app toasts; in-app voice/video messages; SVG icons
Notifications (web push now, Android FCM ready, iOS prepared):
- device_tokens / notification_settings / conversation_mutes (migration 006)
- REST: register/list/unregister devices, get/update settings (quiet hours,
  timezone, enable), mute/unmute conversation, VAPID public key
- BullMQ queue: backend enqueues a job per new message; a dedicated stateless
  `notifications` worker service drains it
- Delivery gating: skip sender, muted chats, disabled users, quiet hours, and
  ONLINE users (they get the message live + an in-app toast) — offline → push
- Providers behind one interface: Web Push (VAPID/web-push) + FCM (firebase-admin,
  HTTP v1; Android + iOS via the same path). Dead tokens (404/410/unregistered)
  auto-retired; transient failures recorded
- Web: service worker (push display + tap-to-open the originating chat), push
  registration/unregistration, in-app toast stack, deep-link via ?conversation=

In-app voice & video messages: MediaRecorder capture in the composer (mic/video),
live preview + timer, upload via the existing presigned-media path.

No emojis anywhere: replaced all glyphs with inline SVG icons (shared/ui) so the
UI renders identically across web/desktop/mobile; notification text is plain.

Chores: dedupe ioredis (BullMQ) + pin uuid>=11.1.1 (audit clean) via pnpm overrides;
reusable Centrifugo client factory for the worker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD
2026-07-10 19:05:39 +05:00

76 lines
3.2 KiB
JavaScript

// Phase 6.5 — notifications: device registry, per-user settings, per-chat mutes.
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.up = (pgm) => {
// Push credentials, one row per device/subscription. `token` is the stable,
// globally-unique identity: the FCM registration token for native, or the
// Web Push endpoint URL for web (p256dh/auth hold the web subscription keys).
pgm.createTable('device_tokens', {
id: { type: 'uuid', notNull: true, default: pgm.func('gen_random_uuid()'), primaryKey: true },
user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' },
platform: { type: 'text', notNull: true },
token: { type: 'text', notNull: true },
web_p256dh: { type: 'text' },
web_auth: { type: 'text' },
failure_count: { type: 'integer', notNull: true, default: 0 },
last_success_at: { type: 'timestamptz' },
disabled_at: { type: 'timestamptz' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint('device_tokens', 'device_tokens_platform_check', {
check: "platform IN ('web', 'android', 'ios')",
});
// A token identifies exactly one device; re-registering rebinds it to its user.
pgm.addConstraint('device_tokens', 'device_tokens_token_unique', { unique: ['token'] });
pgm.createIndex('device_tokens', 'user_id', {
name: 'device_tokens_active_by_user',
where: 'disabled_at IS NULL',
});
// Per-user notification preferences. Quiet hours are minutes-since-midnight in
// the user's IANA timezone; NULL start/end means "no quiet hours".
pgm.createTable('notification_settings', {
user_id: {
type: 'uuid',
notNull: true,
references: 'users',
onDelete: 'CASCADE',
primaryKey: true,
},
enabled: { type: 'boolean', notNull: true, default: true },
quiet_hours_start: { type: 'smallint' },
quiet_hours_end: { type: 'smallint' },
timezone: { type: 'text', notNull: true, default: 'UTC' },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint('notification_settings', 'notification_settings_quiet_range_check', {
check: `
(quiet_hours_start IS NULL OR (quiet_hours_start >= 0 AND quiet_hours_start <= 1439))
AND (quiet_hours_end IS NULL OR (quiet_hours_end >= 0 AND quiet_hours_end <= 1439))
`,
});
// Per-conversation mute. muted_until NULL means muted indefinitely.
pgm.createTable('conversation_mutes', {
conversation_id: {
type: 'uuid',
notNull: true,
references: 'conversations',
onDelete: 'CASCADE',
},
user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' },
muted_until: { type: 'timestamptz' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint('conversation_mutes', 'conversation_mutes_pkey', {
primaryKey: ['conversation_id', 'user_id'],
});
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.down = (pgm) => {
pgm.dropTable('conversation_mutes');
pgm.dropTable('notification_settings');
pgm.dropTable('device_tokens');
};