Spaces:
Sleeping
Sleeping
File size: 11,793 Bytes
57a889c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 | import type Database from 'better-sqlite3';
import { db } from '../db/database';
import { decrypt_api_key } from './apiKeyCrypto';
// ββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export type NotifChannel = 'email' | 'webhook' | 'inapp' | 'ntfy';
export type NotifEventType =
| 'trip_invite'
| 'booking_change'
| 'trip_reminder'
| 'todo_due'
| 'vacay_invite'
| 'photos_shared'
| 'collab_message'
| 'packing_tagged'
| 'version_available'
| 'synology_session_cleared';
export interface AvailableChannels {
email: boolean;
webhook: boolean;
inapp: boolean;
ntfy: boolean;
}
// Which channels are implemented for each event type.
// Only implemented combos show toggles in the user preferences UI.
const IMPLEMENTED_COMBOS: Record<NotifEventType, NotifChannel[]> = {
trip_invite: ['inapp', 'email', 'webhook', 'ntfy'],
booking_change: ['inapp', 'email', 'webhook', 'ntfy'],
trip_reminder: ['inapp', 'email', 'webhook', 'ntfy'],
todo_due: ['inapp', 'email', 'webhook', 'ntfy'],
vacay_invite: ['inapp', 'email', 'webhook', 'ntfy'],
photos_shared: ['inapp', 'email', 'webhook', 'ntfy'],
collab_message: ['inapp', 'email', 'webhook', 'ntfy'],
packing_tagged: ['inapp', 'email', 'webhook', 'ntfy'],
version_available: ['inapp', 'email', 'webhook', 'ntfy'],
synology_session_cleared: ['inapp'],
};
/** Events that target admins only (shown in admin panel, not in user settings). */
export const ADMIN_SCOPED_EVENTS = new Set<NotifEventType>(['version_available']);
// ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function getAppSetting(key: string): string | null {
return (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value || null;
}
// ββ Active channels (admin-configured) ββββββββββββββββββββββββββββββββββββ
/**
* Returns which channels the admin has enabled (email and/or webhook).
* Reads `notification_channels` (plural) with fallback to `notification_channel` (singular).
* In-app is always considered active at the service level.
*/
export function getActiveChannels(): NotifChannel[] {
const raw = getAppSetting('notification_channels') || getAppSetting('notification_channel') || 'none';
if (raw === 'none') return [];
return raw.split(',').map(c => c.trim()).filter((c): c is NotifChannel => c === 'email' || c === 'webhook' || c === 'ntfy');
}
/**
* Returns which channels are configured (have valid credentials/URLs set).
* In-app is always available. Email/webhook depend on configuration.
*/
export function getAvailableChannels(): AvailableChannels {
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
const activeChannels = getActiveChannels();
return { email: hasSmtp, webhook: activeChannels.includes('webhook'), ntfy: activeChannels.includes('ntfy'), inapp: true };
}
// ββ Per-user preference checks βββββββββββββββββββββββββββββββββββββββββββββ
/**
* Returns true if the user has this event+channel enabled.
* Default (no row) = enabled. Only returns false if there's an explicit disabled row.
*/
export function isEnabledForEvent(userId: number, eventType: NotifEventType, channel: NotifChannel): boolean {
const row = db.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(userId, eventType, channel) as { enabled: number } | undefined;
return row === undefined || row.enabled === 1;
}
// ββ Preferences matrix βββββββββββββββββββββββββββββββββββββββββββββββββββββ
export interface PreferencesMatrix {
preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>>;
available_channels: AvailableChannels;
event_types: NotifEventType[];
implemented_combos: Record<NotifEventType, NotifChannel[]>;
defaults?: { ntfyServer: string | null };
}
/**
* Returns the preferences matrix for a user.
* scope='user' β excludes admin-scoped events (for user settings page)
* scope='admin' β returns only admin-scoped events (for admin notifications tab)
*/
export function getPreferencesMatrix(userId: number, userRole: string, scope: 'user' | 'admin' = 'user'): PreferencesMatrix {
const rows = db.prepare(
'SELECT event_type, channel, enabled FROM notification_channel_preferences WHERE user_id = ?'
).all(userId) as Array<{ event_type: string; channel: string; enabled: number }>;
// Build a lookup from stored rows
const stored: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
for (const row of rows) {
if (!stored[row.event_type]) stored[row.event_type] = {};
stored[row.event_type]![row.channel] = row.enabled === 1;
}
// Build the full matrix with defaults (true when no row exists)
const preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>> = {};
const allEvents = Object.keys(IMPLEMENTED_COMBOS) as NotifEventType[];
for (const eventType of allEvents) {
const channels = IMPLEMENTED_COMBOS[eventType];
preferences[eventType] = {};
for (const channel of channels) {
// Admin-scoped events use global settings for email/webhook/ntfy
if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && (channel === 'email' || channel === 'webhook' || channel === 'ntfy')) {
preferences[eventType]![channel] = getAdminGlobalPref(eventType, channel);
} else {
preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true;
}
}
}
// Filter event types by scope
const event_types = scope === 'admin'
? allEvents.filter(e => ADMIN_SCOPED_EVENTS.has(e))
: allEvents.filter(e => !ADMIN_SCOPED_EVENTS.has(e));
// Available channels depend on scope
let available_channels: AvailableChannels;
if (scope === 'admin') {
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
const hasAdminWebhook = !!(getAppSetting('admin_webhook_url'));
const hasAdminNtfy = !!(getAppSetting('admin_ntfy_topic'));
available_channels = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy, inapp: true };
} else {
const activeChannels = getActiveChannels();
available_channels = {
email: activeChannels.includes('email'),
webhook: activeChannels.includes('webhook'),
ntfy: activeChannels.includes('ntfy'),
inapp: true,
};
}
return {
preferences,
available_channels,
event_types,
implemented_combos: IMPLEMENTED_COMBOS,
...(scope === 'user' && { defaults: { ntfyServer: getAppSetting('admin_ntfy_server') || null } }),
};
}
// ββ Admin global preferences (stored in app_settings) βββββββββββββββββββββ
const ADMIN_GLOBAL_CHANNELS: NotifChannel[] = ['email', 'webhook', 'ntfy'];
/**
* Returns the global admin preference for an event+channel.
* Stored in app_settings as `admin_notif_pref_{event}_{channel}`.
* Defaults to true (enabled) when no row exists.
*/
export function getAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook' | 'ntfy'): boolean {
const val = getAppSetting(`admin_notif_pref_${event}_${channel}`);
return val !== '0';
}
function setAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook' | 'ntfy', enabled: boolean): void {
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(
`admin_notif_pref_${event}_${channel}`,
enabled ? '1' : '0'
);
}
// ββ Preferences update βββββββββββββββββββββββββββββββββββββββββββββββββββββ
// ββ Shared helper for per-user channel preference upserts βββββββββββββββββ
function applyUserChannelPrefs(
userId: number,
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>,
upsert: Database.Statement<unknown[]>,
del: Database.Statement<unknown[]>
): void {
for (const [eventType, channels] of Object.entries(prefs)) {
if (!channels) continue;
for (const [channel, enabled] of Object.entries(channels)) {
if (enabled) {
// Remove explicit row β default is enabled
del.run(userId, eventType, channel);
} else {
upsert.run(userId, eventType, channel, 0);
}
}
}
}
/**
* Bulk-update preferences from the matrix UI.
* Inserts disabled rows (enabled=0) and removes rows that are enabled (default).
*/
export function setPreferences(
userId: number,
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>
): void {
const upsert = db.prepare(
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
);
const del = db.prepare(
'DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
);
db.transaction(() => applyUserChannelPrefs(userId, prefs, upsert, del))();
}
/**
* Bulk-update admin notification preferences.
* email/webhook channels are stored globally in app_settings (not per-user).
* inapp channel remains per-user in notification_channel_preferences.
*/
export function setAdminPreferences(
userId: number,
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>
): void {
const upsert = db.prepare(
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
);
const del = db.prepare(
'DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
);
// Split global (email/webhook) from per-user (inapp) prefs
const globalPrefs: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
const userPrefs: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
for (const [eventType, channels] of Object.entries(prefs)) {
if (!channels) continue;
for (const [channel, enabled] of Object.entries(channels)) {
if (ADMIN_GLOBAL_CHANNELS.includes(channel as NotifChannel)) {
if (!globalPrefs[eventType]) globalPrefs[eventType] = {};
globalPrefs[eventType]![channel] = enabled;
} else {
if (!userPrefs[eventType]) userPrefs[eventType] = {};
userPrefs[eventType]![channel] = enabled;
}
}
}
// Apply global prefs outside the transaction (they write to app_settings)
for (const [eventType, channels] of Object.entries(globalPrefs)) {
if (!channels) continue;
for (const [channel, enabled] of Object.entries(channels)) {
setAdminGlobalPref(eventType as NotifEventType, channel as 'email' | 'webhook' | 'ntfy', enabled);
}
}
// Apply per-user (inapp) prefs in a transaction
db.transaction(() => applyUserChannelPrefs(userId, userPrefs, upsert, del))();
}
// ββ SMTP availability helper (for authService) βββββββββββββββββββββββββββββ
export function isSmtpConfigured(): boolean {
return !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
}
export function isWebhookConfigured(): boolean {
return getActiveChannels().includes('webhook');
}
|