Messenger/packages/backend/src/config.ts
Заид Омар Медхат | 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

153 lines
5.1 KiB
TypeScript

// Typed, validated environment configuration. All backend config comes from env
// (12-factor); missing or malformed required values fail fast at startup rather
// than surfacing as confusing runtime errors. No `any`, no assertions.
export interface MinioConfig {
endpoint: string;
port: number;
useSSL: boolean;
accessKey: string;
secretKey: string;
/** S3 region; set explicitly so presigning never triggers a network region lookup. */
region: string;
/** Browser-facing base URL; presigned URLs are signed for this host. */
publicUrl: string;
buckets: {
media: string;
avatars: string;
};
}
export interface CentrifugoConfig {
apiUrl: string;
apiKey: string;
tokenHmacSecret: string;
tokenTtlSeconds: number;
}
export interface AuthConfig {
accessSecret: string;
accessTtlSeconds: number;
refreshTtlSeconds: number;
}
export interface WebPushConfig {
publicKey: string;
privateKey: string;
/** VAPID contact — a mailto: or https: URL. */
subject: string;
}
export interface NotificationsConfig {
/** null when VAPID keys are unset → Web Push disabled. */
webPush: WebPushConfig | null;
/** Path to the Firebase service-account JSON; null → native (FCM) push disabled. */
fcmServiceAccountFile: string | null;
/** APNs is prepared but never enabled until Apple credentials exist (routes via FCM). */
apnsEnabled: boolean;
queueName: string;
}
export interface AppConfig {
nodeEnv: string;
port: number;
databaseUrl: string;
redisUrl: string;
corsOrigins: string[];
minio: MinioConfig;
centrifugo: CentrifugoConfig;
auth: AuthConfig;
notifications: NotificationsConfig;
}
const required = (name: string): string => {
const value = process.env[name];
if (value === undefined || value === '') {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
};
const optional = (name: string, fallback: string): string => {
const value = process.env[name];
if (value === undefined || value === '') {
return fallback;
}
return value;
};
const parsePort = (name: string, raw: string): number => {
const port = Number.parseInt(raw, 10);
if (Number.isNaN(port) || port <= 0 || port > 65535) {
throw new Error(`Environment variable ${name} is not a valid port: ${raw}`);
}
return port;
};
const parseBoolean = (raw: string): boolean => raw.toLowerCase() === 'true';
const buildNotificationsConfig = (): NotificationsConfig => {
const publicKey = optional('VAPID_PUBLIC_KEY', '');
const privateKey = optional('VAPID_PRIVATE_KEY', '');
const webPush =
publicKey !== '' && privateKey !== ''
? { publicKey, privateKey, subject: optional('VAPID_SUBJECT', 'mailto:admin@altricade.com') }
: null;
const fcmFile = optional('FCM_SERVICE_ACCOUNT_FILE', '');
return {
webPush,
fcmServiceAccountFile: fcmFile === '' ? null : fcmFile,
apnsEnabled: parseBoolean(optional('APNS_ENABLED', 'false')),
queueName: optional('NOTIFICATIONS_QUEUE', 'notifications'),
};
};
// Parse a duration like "900", "15s", "15m", "1h", "30d" into seconds.
const DURATION_UNITS: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };
const parseDurationSeconds = (name: string, raw: string): number => {
const match = /^(\d+)([smhd])?$/.exec(raw);
if (match === null) {
throw new Error(`Environment variable ${name} is not a valid duration: ${raw}`);
}
const amount = Number.parseInt(match[1] ?? '', 10);
const unit = match[2];
const multiplier = unit === undefined ? 1 : (DURATION_UNITS[unit] ?? 1);
return amount * multiplier;
};
export const loadConfig = (): AppConfig => ({
nodeEnv: optional('NODE_ENV', 'development'),
port: parsePort('BACKEND_PORT', optional('BACKEND_PORT', '4000')),
databaseUrl: required('DATABASE_URL'),
redisUrl: required('REDIS_URL'),
corsOrigins: optional('CORS_ORIGINS', 'http://localhost:5173,http://localhost:8080')
.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0),
minio: {
endpoint: required('MINIO_ENDPOINT'),
port: parsePort('MINIO_PORT', optional('MINIO_PORT', '9000')),
useSSL: parseBoolean(optional('MINIO_USE_SSL', 'false')),
accessKey: required('MINIO_ROOT_USER'),
secretKey: required('MINIO_ROOT_PASSWORD'),
region: optional('MINIO_REGION', 'us-east-1'),
publicUrl: optional('MINIO_PUBLIC_URL', 'http://localhost:9000'),
buckets: {
media: optional('MINIO_BUCKET_MEDIA', 'media'),
avatars: optional('MINIO_BUCKET_AVATARS', 'avatars'),
},
},
centrifugo: {
apiUrl: required('CENTRIFUGO_API_URL'),
apiKey: required('CENTRIFUGO_API_KEY'),
tokenHmacSecret: required('CENTRIFUGO_TOKEN_HMAC_SECRET'),
tokenTtlSeconds: parseDurationSeconds('CENTRIFUGO_TOKEN_TTL', optional('CENTRIFUGO_TOKEN_TTL', '1h')),
},
auth: {
accessSecret: required('JWT_ACCESS_SECRET'),
accessTtlSeconds: parseDurationSeconds('ACCESS_TOKEN_TTL', optional('ACCESS_TOKEN_TTL', '15m')),
refreshTtlSeconds: parseDurationSeconds('REFRESH_TOKEN_TTL', optional('REFRESH_TOKEN_TTL', '30d')),
},
notifications: buildNotificationsConfig(),
});