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
76 lines
3.2 KiB
JavaScript
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');
|
|
};
|