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
153 lines
5.1 KiB
TypeScript
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(),
|
|
});
|