mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
refactor(notifications, place-photos, backup): async DB-touching methods; transactions through UnitOfWork
This commit is contained in:
@@ -2722,7 +2722,7 @@ export class MapsService {
|
||||
const noPhoto = { photoUrl: null, attribution: null };
|
||||
|
||||
// Recent miss — don't hammer the API
|
||||
if (this.photoCache.getErrored(placeId)) return noPhoto;
|
||||
if (await this.photoCache.getErrored(placeId)) return noPhoto;
|
||||
|
||||
// Deduplicate concurrent requests for the same placeId
|
||||
const existing = this.photoCache.getInFlight(placeId);
|
||||
@@ -2860,7 +2860,7 @@ export class MapsService {
|
||||
const fallback = await fetchWikimediaFallback();
|
||||
if (fallback) return fallback;
|
||||
|
||||
this.photoCache.markError(placeId, providerFailed ? 'provider-error' : 'no-photo');
|
||||
await this.photoCache.markError(placeId, providerFailed ? 'provider-error' : 'no-photo');
|
||||
return null;
|
||||
} finally {
|
||||
releasePhotoFetchSlot();
|
||||
|
||||
@@ -43,14 +43,14 @@ export function buildBuiltinChannels({ mailer, webhook, ntfy }: BuiltinChannelDe
|
||||
bypassesActiveToggleForAdminEvents: true,
|
||||
supportsEvent: supportsAllButSynology,
|
||||
isInstanceConfigured: () => mailer.isSmtpConfigured(),
|
||||
isConfiguredFor: (userId) => !!mailer.getUserEmail(userId),
|
||||
isConfiguredFor: async (userId) => !!(await mailer.getUserEmail(userId)),
|
||||
async sendToUser(userId, msg) {
|
||||
const email = mailer.getUserEmail(userId);
|
||||
const email = await mailer.getUserEmail(userId);
|
||||
if (!email) return false;
|
||||
return mailer.sendEmail(email, msg.title, msg.body, userId, msg.navigateTarget);
|
||||
},
|
||||
async test(userId) {
|
||||
const email = mailer.getUserEmail(userId);
|
||||
const email = await mailer.getUserEmail(userId);
|
||||
if (!email) return { success: false, error: 'No email address on file' };
|
||||
return mailer.testSmtp(email);
|
||||
},
|
||||
@@ -62,19 +62,19 @@ export function buildBuiltinChannels({ mailer, webhook, ntfy }: BuiltinChannelDe
|
||||
labelKey: 'settings.notificationPreferences.webhook',
|
||||
supportsAdminGlobal: true,
|
||||
supportsEvent: supportsAllButSynology,
|
||||
isConfiguredFor: (userId) => !!webhook.getUserWebhookUrl(userId),
|
||||
isConfiguredFor: async (userId) => !!(await webhook.getUserWebhookUrl(userId)),
|
||||
async sendToUser(userId, msg) {
|
||||
const url = webhook.getUserWebhookUrl(userId);
|
||||
const url = await webhook.getUserWebhookUrl(userId);
|
||||
if (!url) return false;
|
||||
return webhook.sendWebhook(url, { event: msg.event, title: msg.title, body: msg.body, tripName: msg.tripName, link: msg.url });
|
||||
},
|
||||
async sendGlobal(msg: ChannelMessage) {
|
||||
const url = webhook.getAdminWebhookUrl();
|
||||
const url = await webhook.getAdminWebhookUrl();
|
||||
if (!url) return false;
|
||||
return webhook.sendWebhook(url, { event: msg.event, title: msg.title, body: msg.body, link: msg.url });
|
||||
},
|
||||
async test(userId, override) {
|
||||
const url = (typeof override?.url === 'string' && override.url) || webhook.getUserWebhookUrl(userId);
|
||||
const url = (typeof override?.url === 'string' && override.url) || (await webhook.getUserWebhookUrl(userId));
|
||||
if (!url) return { success: false, error: 'No webhook URL configured' };
|
||||
return webhook.testWebhook(url);
|
||||
},
|
||||
@@ -86,10 +86,10 @@ export function buildBuiltinChannels({ mailer, webhook, ntfy }: BuiltinChannelDe
|
||||
labelKey: 'settings.notificationPreferences.ntfy',
|
||||
supportsAdminGlobal: true,
|
||||
supportsEvent: supportsAllButSynology,
|
||||
isConfiguredFor: (userId) => !!resolveNtfyUrl(ntfy.getAdminNtfyConfig(), ntfy.getUserNtfyConfig(userId)),
|
||||
isConfiguredFor: async (userId) => !!resolveNtfyUrl(await ntfy.getAdminNtfyConfig(), await ntfy.getUserNtfyConfig(userId)),
|
||||
async sendToUser(userId, msg) {
|
||||
const userCfg = ntfy.getUserNtfyConfig(userId);
|
||||
const adminCfg = ntfy.getAdminNtfyConfig();
|
||||
const userCfg = await ntfy.getUserNtfyConfig(userId);
|
||||
const adminCfg = await ntfy.getAdminNtfyConfig();
|
||||
const url = resolveNtfyUrl(adminCfg, userCfg);
|
||||
if (!url) return false;
|
||||
// Not `?? adminCfg.token`: the user picks their own ntfy_server, so that
|
||||
@@ -98,13 +98,13 @@ export function buildBuiltinChannels({ mailer, webhook, ntfy }: BuiltinChannelDe
|
||||
return ntfy.sendNtfy(url, resolveNtfyToken(adminCfg, userCfg), { event: msg.event, title: msg.title, body: msg.body, link: msg.url });
|
||||
},
|
||||
async sendGlobal(msg: ChannelMessage) {
|
||||
const adminCfg = ntfy.getAdminNtfyConfig();
|
||||
const adminCfg = await ntfy.getAdminNtfyConfig();
|
||||
const url = resolveAdminNtfyUrl(adminCfg);
|
||||
if (!url) return false;
|
||||
return ntfy.sendNtfy(url, adminCfg.token, { event: msg.event, title: msg.title, body: msg.body, link: msg.url });
|
||||
},
|
||||
async test(userId, override) {
|
||||
const topic = typeof override?.topic === 'string' ? override.topic : ntfy.getUserNtfyConfig(userId)?.topic;
|
||||
const topic = typeof override?.topic === 'string' ? override.topic : (await ntfy.getUserNtfyConfig(userId))?.topic;
|
||||
if (!topic) return { success: false, error: 'Could not resolve ntfy URL — missing topic' };
|
||||
return ntfy.testNtfy({
|
||||
topic,
|
||||
|
||||
@@ -56,24 +56,24 @@ export class MailerService {
|
||||
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
private getAppSetting(key: string): string | null {
|
||||
private async getAppSetting(key: string): Promise<string | null> {
|
||||
return this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value || null;
|
||||
}
|
||||
|
||||
/** Env wins over the admin panel, per field. Read fresh on every send. */
|
||||
private readSmtpSettings() {
|
||||
private async readSmtpSettings() {
|
||||
const smtpEnv = readEnv().smtp;
|
||||
return {
|
||||
host: smtpEnv.host || this.getAppSetting('smtp_host'),
|
||||
port: smtpEnv.port || this.getAppSetting('smtp_port'),
|
||||
user: smtpEnv.user || this.getAppSetting('smtp_user'),
|
||||
pass: smtpEnv.pass || decrypt_api_key(this.getAppSetting('smtp_pass')) || '',
|
||||
from: smtpEnv.from || this.getAppSetting('smtp_from'),
|
||||
host: smtpEnv.host || (await this.getAppSetting('smtp_host')),
|
||||
port: smtpEnv.port || (await this.getAppSetting('smtp_port')),
|
||||
user: smtpEnv.user || (await this.getAppSetting('smtp_user')),
|
||||
pass: smtpEnv.pass || decrypt_api_key(await this.getAppSetting('smtp_pass')) || '',
|
||||
from: smtpEnv.from || (await this.getAppSetting('smtp_from')),
|
||||
};
|
||||
}
|
||||
|
||||
private getSmtpConfig(): SmtpConfig | null {
|
||||
const { host, port, user, pass, from } = this.readSmtpSettings();
|
||||
private async getSmtpConfig(): Promise<SmtpConfig | null> {
|
||||
const { host, port, user, pass, from } = await this.readSmtpSettings();
|
||||
// An unusable port counts as unconfigured rather than as a dial: net.connect
|
||||
// on NaN throws from inside nodemailer, where the reason gets lost.
|
||||
const portNumber = parseSmtpPort(port);
|
||||
@@ -95,8 +95,8 @@ export class MailerService {
|
||||
* The three call sites below each built this inline before the fold; keeping it
|
||||
* one method is the only change, and it keeps the freshness property visible.
|
||||
*/
|
||||
private createTransport(config: SmtpConfig, socketTimeoutMs: number = SOCKET_TIMEOUT_MS) {
|
||||
const skipTls = readEnv().smtp.skipTlsVerify || this.getAppSetting('smtp_skip_tls_verify') === 'true';
|
||||
private async createTransport(config: SmtpConfig, socketTimeoutMs: number = SOCKET_TIMEOUT_MS) {
|
||||
const skipTls = readEnv().smtp.skipTlsVerify || (await this.getAppSetting('smtp_skip_tls_verify')) === 'true';
|
||||
if (skipTls) this.warnOnceAboutSkippedTls(config);
|
||||
return nodemailer.createTransport({
|
||||
host: config.host,
|
||||
@@ -130,18 +130,18 @@ export class MailerService {
|
||||
}
|
||||
|
||||
/** Is SMTP configured at the instance level? (Independent of any one user's address.) */
|
||||
isSmtpConfigured(): boolean {
|
||||
return !!(readEnv().smtp.host || this.getAppSetting('smtp_host'));
|
||||
async isSmtpConfigured(): Promise<boolean> {
|
||||
return !!(readEnv().smtp.host || (await this.getAppSetting('smtp_host')));
|
||||
}
|
||||
|
||||
getUserEmail(userId: number): string | null {
|
||||
async getUserEmail(userId: number): Promise<string | null> {
|
||||
// Defense-in-depth (#1362): a guest's synthetic email must never be emailed.
|
||||
return this.db.get<{ email: string }>(
|
||||
'SELECT email FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0', userId,
|
||||
)?.email || null;
|
||||
}
|
||||
|
||||
getUserLanguage(userId: number): string {
|
||||
async getUserLanguage(userId: number): Promise<string> {
|
||||
return this.db.get<{ value: string }>(
|
||||
"SELECT value FROM settings WHERE user_id = ? AND key = 'language'", userId,
|
||||
)?.value || 'en';
|
||||
@@ -160,9 +160,9 @@ export class MailerService {
|
||||
resetUrl: string,
|
||||
userId: number | null,
|
||||
): Promise<{ delivered: 'email' | 'log' | 'failed' }> {
|
||||
const lang = userId ? this.getUserLanguage(userId) : 'en';
|
||||
const lang = userId ? await this.getUserLanguage(userId) : 'en';
|
||||
const strings = PASSWORD_RESET_I18N[lang] || PASSWORD_RESET_I18N.en;
|
||||
const smtpCfg = this.getSmtpConfig();
|
||||
const smtpCfg = await this.getSmtpConfig();
|
||||
|
||||
if (!smtpCfg) {
|
||||
// No SMTP configured — log the link in a visually distinct block so
|
||||
@@ -182,7 +182,7 @@ export class MailerService {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.createTransport(smtpCfg).sendMail({
|
||||
await (await this.createTransport(smtpCfg)).sendMail({
|
||||
from: smtpCfg.from,
|
||||
to,
|
||||
subject: `TREK — ${strings.subject}`,
|
||||
@@ -204,13 +204,13 @@ export class MailerService {
|
||||
userId?: number,
|
||||
navigateTarget?: string,
|
||||
): Promise<boolean> {
|
||||
const config = this.getSmtpConfig();
|
||||
const config = await this.getSmtpConfig();
|
||||
if (!config) return false;
|
||||
|
||||
const lang = userId ? this.getUserLanguage(userId) : 'en';
|
||||
const lang = userId ? await this.getUserLanguage(userId) : 'en';
|
||||
|
||||
try {
|
||||
await this.createTransport(config).sendMail({
|
||||
await (await this.createTransport(config)).sendMail({
|
||||
from: config.from,
|
||||
to,
|
||||
subject: `TREK — ${subject}`,
|
||||
@@ -234,15 +234,15 @@ export class MailerService {
|
||||
* blocked port.
|
||||
*/
|
||||
async testSmtp(to: string): Promise<{ success: boolean; error?: string }> {
|
||||
const config = this.getSmtpConfig();
|
||||
const config = await this.getSmtpConfig();
|
||||
if (!config) {
|
||||
const { host, port, from } = this.readSmtpSettings();
|
||||
const { host, port, from } = await this.readSmtpSettings();
|
||||
const reason = describeSmtpGap({ host, port, from });
|
||||
logWarn(`SMTP test not attempted to=${to}: ${reason}`);
|
||||
return { success: false, error: reason };
|
||||
}
|
||||
try {
|
||||
await this.createTransport(config, TEST_SOCKET_TIMEOUT_MS).sendMail({
|
||||
await (await this.createTransport(config, TEST_SOCKET_TIMEOUT_MS)).sendMail({
|
||||
from: config.from,
|
||||
to,
|
||||
subject: 'TREK — Test Notification',
|
||||
|
||||
@@ -133,7 +133,7 @@ export interface ExternalChannel {
|
||||
* Instance-level readiness, independent of any one recipient (email: is SMTP set
|
||||
* up at all?). Absent means "always ready".
|
||||
*/
|
||||
isInstanceConfigured?(): boolean;
|
||||
isInstanceConfigured?(): boolean | Promise<boolean>;
|
||||
/** Does this recipient have credentials for this channel? */
|
||||
isConfiguredFor(userId: number): boolean | Promise<boolean>;
|
||||
sendToUser(userId: number, msg: ChannelMessage): Promise<unknown>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { MailerService } from './mailer/mailer.service';
|
||||
import { listChannels } from './channel-registry';
|
||||
import {
|
||||
@@ -35,9 +36,10 @@ export class NotificationPreferencesService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly mailer: MailerService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
private getAppSetting(key: string): string | null {
|
||||
private async getAppSetting(key: string): Promise<string | null> {
|
||||
return this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value || null;
|
||||
}
|
||||
|
||||
@@ -69,7 +71,7 @@ export class NotificationPreferencesService {
|
||||
* would silently drop any that were).
|
||||
*/
|
||||
async getActiveChannels(): Promise<NotifChannel[]> {
|
||||
const raw = this.getAppSetting('notification_channels') || this.getAppSetting('notification_channel') || 'none';
|
||||
const raw = (await this.getAppSetting('notification_channels')) || (await this.getAppSetting('notification_channel')) || 'none';
|
||||
if (raw === 'none') return [];
|
||||
const builtins = new Set((await listChannels()).filter(c => c.source === 'builtin').map(c => c.id));
|
||||
return raw.split(',').map(c => c.trim()).filter(c => builtins.has(c));
|
||||
@@ -86,7 +88,7 @@ export class NotificationPreferencesService {
|
||||
* Returns true if the user has this event+channel enabled.
|
||||
* Default (no row) = enabled. Only returns false if there's an explicit disabled row.
|
||||
*/
|
||||
isEnabledForEvent(userId: number, eventType: NotifEventType, channel: NotifChannel): boolean {
|
||||
async isEnabledForEvent(userId: number, eventType: NotifEventType, channel: NotifChannel): Promise<boolean> {
|
||||
const row = this.db.get<{ enabled: number }>(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?',
|
||||
userId, eventType, channel,
|
||||
@@ -118,9 +120,9 @@ export class NotificationPreferencesService {
|
||||
if (scope === 'admin') {
|
||||
// Admin-scoped events go out over the admin's own global credentials, which
|
||||
// are independent of the per-user `notification_channels` toggle.
|
||||
const hasSmtp = this.mailer.isSmtpConfigured();
|
||||
const hasAdminWebhook = !!this.getAppSetting('admin_webhook_url');
|
||||
const hasAdminNtfy = !!this.getAppSetting('admin_ntfy_topic');
|
||||
const hasSmtp = await this.mailer.isSmtpConfigured();
|
||||
const hasAdminWebhook = !!(await this.getAppSetting('admin_webhook_url'));
|
||||
const hasAdminNtfy = !!(await this.getAppSetting('admin_ntfy_topic'));
|
||||
const adminActive: Record<string, boolean> = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy };
|
||||
for (const channel of await listChannels()) {
|
||||
// Plugin channels are user-scoped only — they never carry admin-global events.
|
||||
@@ -181,7 +183,7 @@ export class NotificationPreferencesService {
|
||||
for (const channel of implemented_combos[eventType]) {
|
||||
// Admin-scoped events use global settings for the built-in external channels
|
||||
if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && isAdminGlobalChannel(channel)) {
|
||||
preferences[eventType]![channel] = this.getAdminGlobalPref(eventType, channel);
|
||||
preferences[eventType]![channel] = await this.getAdminGlobalPref(eventType, channel);
|
||||
} else {
|
||||
preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true;
|
||||
}
|
||||
@@ -198,7 +200,7 @@ export class NotificationPreferencesService {
|
||||
channels: await this.describeChannels(userId, scope),
|
||||
event_types,
|
||||
implemented_combos,
|
||||
...(scope === 'user' && { defaults: { ntfyServer: this.getAppSetting('admin_ntfy_server') || null } }),
|
||||
...(scope === 'user' && { defaults: { ntfyServer: (await this.getAppSetting('admin_ntfy_server')) || null } }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -209,12 +211,12 @@ export class NotificationPreferencesService {
|
||||
* Stored in app_settings as `admin_notif_pref_{event}_{channel}`.
|
||||
* Defaults to true (enabled) when no row exists.
|
||||
*/
|
||||
getAdminGlobalPref(event: NotifEventType, channel: AdminGlobalChannel): boolean {
|
||||
const val = this.getAppSetting(`admin_notif_pref_${event}_${channel}`);
|
||||
async getAdminGlobalPref(event: NotifEventType, channel: AdminGlobalChannel): Promise<boolean> {
|
||||
const val = await this.getAppSetting(`admin_notif_pref_${event}_${channel}`);
|
||||
return val !== '0';
|
||||
}
|
||||
|
||||
private setAdminGlobalPref(event: NotifEventType, channel: AdminGlobalChannel, enabled: boolean): void {
|
||||
private async setAdminGlobalPref(event: NotifEventType, channel: AdminGlobalChannel, enabled: boolean): Promise<void> {
|
||||
this.db.run('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)',
|
||||
`admin_notif_pref_${event}_${channel}`,
|
||||
enabled ? '1' : '0',
|
||||
@@ -224,10 +226,10 @@ export class NotificationPreferencesService {
|
||||
// ── Preferences update ─────────────────────────────────────────────────────
|
||||
|
||||
/** Shared helper for per-user channel preference upserts. */
|
||||
private applyUserChannelPrefs(
|
||||
private async applyUserChannelPrefs(
|
||||
userId: number,
|
||||
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const upsert = this.db.prepare(
|
||||
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
@@ -251,11 +253,13 @@ export class NotificationPreferencesService {
|
||||
* Bulk-update preferences from the matrix UI.
|
||||
* Inserts disabled rows (enabled=0) and removes rows that are enabled (default).
|
||||
*/
|
||||
setPreferences(
|
||||
async setPreferences(
|
||||
userId: number,
|
||||
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>
|
||||
): void {
|
||||
this.db.transaction(() => this.applyUserChannelPrefs(userId, prefs));
|
||||
): Promise<void> {
|
||||
await this.uow.transactional(async () => {
|
||||
await this.applyUserChannelPrefs(userId, prefs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,10 +267,10 @@ export class NotificationPreferencesService {
|
||||
* email/webhook channels are stored globally in app_settings (not per-user).
|
||||
* inapp channel remains per-user in notification_channel_preferences.
|
||||
*/
|
||||
setAdminPreferences(
|
||||
async setAdminPreferences(
|
||||
userId: number,
|
||||
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>
|
||||
): void {
|
||||
): Promise<void> {
|
||||
// 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>>>> = {};
|
||||
@@ -289,19 +293,21 @@ export class NotificationPreferencesService {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
if (!isAdminGlobalChannel(channel)) continue;
|
||||
this.setAdminGlobalPref(eventType as NotifEventType, channel, enabled);
|
||||
await this.setAdminGlobalPref(eventType as NotifEventType, channel, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply per-user (inapp) prefs in a transaction
|
||||
this.db.transaction(() => this.applyUserChannelPrefs(userId, userPrefs));
|
||||
await this.uow.transactional(async () => {
|
||||
await this.applyUserChannelPrefs(userId, userPrefs);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Instance-level readiness ──────────────────────────────────────────────
|
||||
|
||||
/** SMTP set up at all? Kept here because the settings UI asks preferences, not the mailer. */
|
||||
isSmtpConfigured(): boolean {
|
||||
return this.mailer.isSmtpConfigured();
|
||||
async isSmtpConfigured(): Promise<boolean> {
|
||||
return await this.mailer.isSmtpConfigured();
|
||||
}
|
||||
|
||||
async isWebhookConfigured(): Promise<boolean> {
|
||||
|
||||
@@ -59,9 +59,9 @@ export class NotificationsController {
|
||||
}
|
||||
|
||||
@Put('preferences')
|
||||
setPreferences(@CurrentUser() user: User, @Body() body: PreferencesUpdateDto) {
|
||||
this.notifications.setPreferences(user.id, body);
|
||||
return this.notifications.getPreferences(user.id, user.role);
|
||||
async setPreferences(@CurrentUser() user: User, @Body() body: PreferencesUpdateDto) {
|
||||
await this.notifications.setPreferences(user.id, body);
|
||||
return await this.notifications.getPreferences(user.id, user.role);
|
||||
}
|
||||
|
||||
@ManagedForbidden('the relay is the operator credential; a test send would use their reputation')
|
||||
@@ -79,8 +79,8 @@ export class NotificationsController {
|
||||
async testWebhook(@CurrentUser() user: User, @Body() body: TestWebhookDto): Promise<ChannelTestResult> {
|
||||
let url: string | null | undefined = body.url;
|
||||
if (!url || url === MASKED) {
|
||||
url = this.notifications.userWebhookUrl(user.id);
|
||||
if (!url && user.role === 'admin') url = this.notifications.adminWebhookUrl();
|
||||
url = await this.notifications.userWebhookUrl(user.id);
|
||||
if (!url && user.role === 'admin') url = await this.notifications.adminWebhookUrl();
|
||||
if (!url) {
|
||||
throw new HttpException({ error: 'No webhook URL configured' }, 400);
|
||||
}
|
||||
@@ -97,8 +97,8 @@ export class NotificationsController {
|
||||
@HttpCode(200)
|
||||
async testNtfy(@CurrentUser() user: User, @Body() body: TestNtfyDto): Promise<ChannelTestResult> {
|
||||
const { topic, server, token } = body;
|
||||
const userCfg = this.notifications.userNtfyConfig(user.id);
|
||||
const adminCfg = this.notifications.adminNtfyConfig();
|
||||
const userCfg = await this.notifications.userNtfyConfig(user.id);
|
||||
const adminCfg = await this.notifications.adminNtfyConfig();
|
||||
|
||||
const resolvedTopic = topic || userCfg?.topic || undefined;
|
||||
const resolvedServer = server || userCfg?.server || adminCfg.server || undefined;
|
||||
@@ -145,42 +145,42 @@ export class NotificationsController {
|
||||
}
|
||||
|
||||
@Get('in-app/unread-count')
|
||||
unreadCount(@CurrentUser() user: User): UnreadCountResult {
|
||||
return { count: this.notifications.unreadCount(user.id) };
|
||||
async unreadCount(@CurrentUser() user: User): Promise<UnreadCountResult> {
|
||||
return { count: await this.notifications.unreadCount(user.id) };
|
||||
}
|
||||
|
||||
@Put('in-app/read-all')
|
||||
readAll(@CurrentUser() user: User): { success: boolean; count: number } {
|
||||
return { success: true, count: this.notifications.markAllRead(user.id) };
|
||||
async readAll(@CurrentUser() user: User): Promise<{ success: boolean; count: number }> {
|
||||
return { success: true, count: await this.notifications.markAllRead(user.id) };
|
||||
}
|
||||
|
||||
@Delete('in-app/all')
|
||||
deleteAll(@CurrentUser() user: User): { success: boolean; count: number } {
|
||||
return { success: true, count: this.notifications.deleteAll(user.id) };
|
||||
async deleteAll(@CurrentUser() user: User): Promise<{ success: boolean; count: number }> {
|
||||
return { success: true, count: await this.notifications.deleteAll(user.id) };
|
||||
}
|
||||
|
||||
@Put('in-app/:id/read')
|
||||
markRead(@CurrentUser() user: User, @Param('id') idParam: string): { success: boolean } {
|
||||
async markRead(@CurrentUser() user: User, @Param('id') idParam: string): Promise<{ success: boolean }> {
|
||||
const id = this.parseId(idParam);
|
||||
if (!this.notifications.markRead(id, user.id)) {
|
||||
if (!(await this.notifications.markRead(id, user.id))) {
|
||||
throw new HttpException({ error: 'Not found' }, 404);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Put('in-app/:id/unread')
|
||||
markUnread(@CurrentUser() user: User, @Param('id') idParam: string): { success: boolean } {
|
||||
async markUnread(@CurrentUser() user: User, @Param('id') idParam: string): Promise<{ success: boolean }> {
|
||||
const id = this.parseId(idParam);
|
||||
if (!this.notifications.markUnread(id, user.id)) {
|
||||
if (!(await this.notifications.markUnread(id, user.id))) {
|
||||
throw new HttpException({ error: 'Not found' }, 404);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete('in-app/:id')
|
||||
deleteOne(@CurrentUser() user: User, @Param('id') idParam: string): { success: boolean } {
|
||||
async deleteOne(@CurrentUser() user: User, @Param('id') idParam: string): Promise<{ success: boolean }> {
|
||||
const id = this.parseId(idParam);
|
||||
if (!this.notifications.deleteOne(id, user.id)) {
|
||||
if (!(await this.notifications.deleteOne(id, user.id))) {
|
||||
throw new HttpException({ error: 'Not found' }, 404);
|
||||
}
|
||||
return { success: true };
|
||||
@@ -230,10 +230,10 @@ export class AdminNotificationPreferencesController {
|
||||
}
|
||||
|
||||
@Put()
|
||||
set(@CurrentUser() user: User, @Body() body: AdminNotificationPreferencesDto) {
|
||||
this.prefs.setAdminPreferences(user.id, body);
|
||||
async set(@CurrentUser() user: User, @Body() body: AdminNotificationPreferencesDto) {
|
||||
await this.prefs.setAdminPreferences(user.id, body);
|
||||
// Answer with the refreshed matrix rather than the raw write result — the admin
|
||||
// panel renders straight from this response.
|
||||
return this.prefs.getPreferencesMatrix(user.id, user.role, 'admin');
|
||||
return await this.prefs.getPreferencesMatrix(user.id, user.role, 'admin');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export class NotificationsMcp {
|
||||
{ limit, offset, unread_only }: { limit?: number; offset?: number; unread_only?: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const result = this.notifications.listInApp(ctx.userId, { limit: limit ?? 20, offset: offset ?? 0, unreadOnly: unread_only ?? false });
|
||||
const result = await this.notifications.listInApp(ctx.userId, { limit: limit ?? 20, offset: offset ?? 0, unreadOnly: unread_only ?? false });
|
||||
return ok(result);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export class NotificationsMcp {
|
||||
access: { group: 'notifications', mode: 'read' },
|
||||
})
|
||||
async getUnreadNotificationCount(_input: Record<string, never>, ctx: McpContext) {
|
||||
const count = this.notifications.unreadCount(ctx.userId);
|
||||
const count = await this.notifications.unreadCount(ctx.userId);
|
||||
return ok({ count });
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export class NotificationsMcp {
|
||||
})
|
||||
async markNotificationRead({ notificationId }: { notificationId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
const success = this.notifications.markRead(notificationId, ctx.userId);
|
||||
const success = await this.notifications.markRead(notificationId, ctx.userId);
|
||||
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
|
||||
return ok({ success: true });
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export class NotificationsMcp {
|
||||
})
|
||||
async markNotificationUnread({ notificationId }: { notificationId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
const success = this.notifications.markUnread(notificationId, ctx.userId);
|
||||
const success = await this.notifications.markUnread(notificationId, ctx.userId);
|
||||
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
|
||||
return ok({ success: true });
|
||||
}
|
||||
@@ -108,7 +108,7 @@ export class NotificationsMcp {
|
||||
})
|
||||
async markAllNotificationsRead(_input: Record<string, never>, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
const count = this.notifications.markAllRead(ctx.userId);
|
||||
const count = await this.notifications.markAllRead(ctx.userId);
|
||||
return ok({ success: true, count });
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export class NotificationsMcp {
|
||||
access: { group: 'notifications', mode: 'read' },
|
||||
})
|
||||
async inAppNotificationsResource(uri: URL, ctx: McpContext) {
|
||||
const result = this.notifications.listInApp(ctx.userId, { limit: 50 });
|
||||
const result = await this.notifications.listInApp(ctx.userId, { limit: 50 });
|
||||
return jsonContent(uri.href, result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getChannel, listChannels } from './channel-registry';
|
||||
import { getAction } from './in-app-actions';
|
||||
import { avatarUrl } from '../common/avatarUrl';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
|
||||
|
||||
@@ -272,17 +273,17 @@ async function shouldSendToUser(
|
||||
prefs: NotificationPreferencesService,
|
||||
): Promise<boolean> {
|
||||
if (!channel.supportsEvent(event)) return false;
|
||||
if (channel.isInstanceConfigured && !channel.isInstanceConfigured()) return false;
|
||||
if (channel.isInstanceConfigured && !(await channel.isInstanceConfigured())) return false;
|
||||
|
||||
if (ADMIN_SCOPED_EVENTS.has(event)) {
|
||||
if (!channel.bypassesActiveToggleForAdminEvents) return false;
|
||||
if (!isAdminGlobalChannel(channel.id)) return false;
|
||||
if (!prefs.getAdminGlobalPref(event, channel.id)) return false;
|
||||
if (!(await prefs.getAdminGlobalPref(event, channel.id))) return false;
|
||||
} else {
|
||||
// A built-in needs the admin's explicit switch; a plugin channel is on because the
|
||||
// admin enabled the plugin — that IS the opt-in (see getActiveChannels).
|
||||
if (channel.source === 'builtin' && !activeChannels.includes(channel.id)) return false;
|
||||
if (!prefs.isEnabledForEvent(recipientId, event, channel.id)) return false;
|
||||
if (!(await prefs.isEnabledForEvent(recipientId, event, channel.id))) return false;
|
||||
}
|
||||
|
||||
return await channel.isConfiguredFor(recipientId);
|
||||
@@ -316,6 +317,7 @@ export class NotificationsService {
|
||||
private readonly webhook: WebhookService,
|
||||
private readonly ntfy: NtfyService,
|
||||
private readonly prefs: NotificationPreferencesService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {
|
||||
// The registry is a module singleton shared with the plugin runtime and any
|
||||
// separately-constructed instance (which never runs onModuleInit), so
|
||||
@@ -341,8 +343,8 @@ export class NotificationsService {
|
||||
}
|
||||
}
|
||||
|
||||
setPreferences(userId: number, body: Parameters<NotificationPreferencesService['setPreferences']>[1]): void {
|
||||
this.prefs.setPreferences(userId, body);
|
||||
async setPreferences(userId: number, body: Parameters<NotificationPreferencesService['setPreferences']>[1]): Promise<void> {
|
||||
await this.prefs.setPreferences(userId, body);
|
||||
}
|
||||
|
||||
testSmtp(to: string): Promise<ChannelTestResult> {
|
||||
@@ -357,25 +359,25 @@ export class NotificationsService {
|
||||
return this.ntfy.testNtfy(cfg);
|
||||
}
|
||||
|
||||
userWebhookUrl(userId: number): string | null {
|
||||
return this.webhook.getUserWebhookUrl(userId);
|
||||
async userWebhookUrl(userId: number): Promise<string | null> {
|
||||
return await this.webhook.getUserWebhookUrl(userId);
|
||||
}
|
||||
|
||||
adminWebhookUrl(): string | null {
|
||||
return this.webhook.getAdminWebhookUrl();
|
||||
async adminWebhookUrl(): Promise<string | null> {
|
||||
return await this.webhook.getAdminWebhookUrl();
|
||||
}
|
||||
|
||||
userNtfyConfig(userId: number): NtfyConfig | null {
|
||||
return this.ntfy.getUserNtfyConfig(userId);
|
||||
async userNtfyConfig(userId: number): Promise<NtfyConfig | null> {
|
||||
return await this.ntfy.getUserNtfyConfig(userId);
|
||||
}
|
||||
|
||||
adminNtfyConfig(): NtfyConfig {
|
||||
return this.ntfy.getAdminNtfyConfig();
|
||||
async adminNtfyConfig(): Promise<NtfyConfig> {
|
||||
return await this.ntfy.getAdminNtfyConfig();
|
||||
}
|
||||
|
||||
// ── In-app notification store (from services/inAppNotifications.ts) ───────
|
||||
|
||||
resolveRecipients(scope: NotificationScope, target: number, excludeUserId?: number | null): number[] {
|
||||
async resolveRecipients(scope: NotificationScope, target: number, excludeUserId?: number | null): Promise<number[]> {
|
||||
let userIds: number[] = [];
|
||||
|
||||
// Guests (#1362) are trip members for assignment purposes but have no inbox/email,
|
||||
@@ -410,8 +412,8 @@ export class NotificationsService {
|
||||
* one transaction). `send()` resolves recipients itself and uses
|
||||
* `createNotificationForRecipient`; this remains for direct callers/tests.
|
||||
*/
|
||||
createNotification(input: NotificationInput): number[] {
|
||||
const recipients = this.resolveRecipients(input.scope, input.target, input.sender_id);
|
||||
async createNotification(input: NotificationInput): Promise<number[]> {
|
||||
const recipients = await this.resolveRecipients(input.scope, input.target, input.sender_id);
|
||||
if (recipients.length === 0) return [];
|
||||
|
||||
const titleParams = JSON.stringify(input.title_params ?? {});
|
||||
@@ -420,7 +422,7 @@ export class NotificationsService {
|
||||
// Track inserted id → recipientId pairs (some recipients may be skipped by pref check)
|
||||
const insertedPairs: Array<{ id: number; recipientId: number }> = [];
|
||||
|
||||
this.db.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
@@ -432,7 +434,7 @@ export class NotificationsService {
|
||||
|
||||
for (const recipientId of recipients) {
|
||||
// Check per-user in-app preference if an event_type is provided
|
||||
if (input.event_type && !this.prefs.isEnabledForEvent(recipientId, input.event_type, 'inapp')) {
|
||||
if (input.event_type && !(await this.prefs.isEnabledForEvent(recipientId, input.event_type, 'inapp'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -492,11 +494,11 @@ export class NotificationsService {
|
||||
* Insert a single in-app notification for one pre-resolved recipient and broadcast via WebSocket.
|
||||
* Used by send() which handles recipient resolution externally.
|
||||
*/
|
||||
createNotificationForRecipient(
|
||||
async createNotificationForRecipient(
|
||||
input: NotificationInput,
|
||||
recipientId: number,
|
||||
sender: { username: string; avatar: string | null } | null
|
||||
): number | null {
|
||||
): Promise<number | null> {
|
||||
const titleParams = JSON.stringify(input.title_params ?? {});
|
||||
const textParams = JSON.stringify(input.text_params ?? {});
|
||||
|
||||
@@ -550,10 +552,10 @@ export class NotificationsService {
|
||||
|
||||
// Returns the native service shape (NotificationRow[] is a superset of the
|
||||
// client-facing InAppListResult contract); the controller surfaces it as-is.
|
||||
listInApp(
|
||||
async listInApp(
|
||||
userId: number,
|
||||
options: { limit?: number; offset?: number; unreadOnly?: boolean } = {}
|
||||
): { notifications: NotificationRow[]; total: number; unread_count: number } {
|
||||
): Promise<{ notifications: NotificationRow[]; total: number; unread_count: number }> {
|
||||
const limit = Math.min(options.limit ?? 20, 50);
|
||||
const offset = options.offset ?? 0;
|
||||
const unreadOnly = options.unreadOnly ?? false;
|
||||
@@ -582,32 +584,32 @@ export class NotificationsService {
|
||||
return { notifications: mapped, total, unread_count };
|
||||
}
|
||||
|
||||
unreadCount(userId: number): number {
|
||||
async unreadCount(userId: number): Promise<number> {
|
||||
const row = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM notifications WHERE recipient_id = ? AND is_read = 0', userId) as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
|
||||
markRead(notificationId: number, userId: number): boolean {
|
||||
async markRead(notificationId: number, userId: number): Promise<boolean> {
|
||||
const result = this.db.run('UPDATE notifications SET is_read = 1 WHERE id = ? AND recipient_id = ?', notificationId, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
markUnread(notificationId: number, userId: number): boolean {
|
||||
async markUnread(notificationId: number, userId: number): Promise<boolean> {
|
||||
const result = this.db.run('UPDATE notifications SET is_read = 0 WHERE id = ? AND recipient_id = ?', notificationId, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
markAllRead(userId: number): number {
|
||||
async markAllRead(userId: number): Promise<number> {
|
||||
const result = this.db.run('UPDATE notifications SET is_read = 1 WHERE recipient_id = ? AND is_read = 0', userId);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
deleteOne(notificationId: number, userId: number): boolean {
|
||||
async deleteOne(notificationId: number, userId: number): Promise<boolean> {
|
||||
const result = this.db.run('DELETE FROM notifications WHERE id = ? AND recipient_id = ?', notificationId, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
deleteAll(userId: number): number {
|
||||
async deleteAll(userId: number): Promise<number> {
|
||||
const result = this.db.run('DELETE FROM notifications WHERE recipient_id = ?', userId);
|
||||
return result.changes;
|
||||
}
|
||||
@@ -682,7 +684,7 @@ export class NotificationsService {
|
||||
const { event, actorId, params, scope, targetId, inApp } = payload;
|
||||
|
||||
// Resolve recipients based on scope
|
||||
const recipients = this.resolveRecipients(scope, targetId, actorId);
|
||||
const recipients = await this.resolveRecipients(scope, targetId, actorId);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const configEntry = EVENT_NOTIFICATION_CONFIG[event];
|
||||
@@ -690,7 +692,7 @@ export class NotificationsService {
|
||||
logDebug(`notificationService.send: unknown event type "${event}", using fallback`);
|
||||
if (readEnv().app.isDevelopment && actorId != null) {
|
||||
const devSender = this.db.get<{ username: string; avatar: string | null }>('SELECT username, avatar FROM users WHERE id = ?', actorId) ?? null;
|
||||
this.createNotificationForRecipient({
|
||||
await this.createNotificationForRecipient({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: actorId,
|
||||
@@ -726,7 +728,7 @@ export class NotificationsService {
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
// ── In-app ──────────────────────────────────────────────────────────
|
||||
if (this.prefs.isEnabledForEvent(recipientId, event, 'inapp')) {
|
||||
if (await this.prefs.isEnabledForEvent(recipientId, event, 'inapp')) {
|
||||
const inAppType = inApp?.type ?? config.inAppType;
|
||||
let notifInput: NotificationInput;
|
||||
|
||||
@@ -790,7 +792,7 @@ export class NotificationsService {
|
||||
if (await shouldSendToUser(ch, event, recipientId, activeChannels, this.prefs)) deliverable.push(ch);
|
||||
}
|
||||
if (deliverable.length > 0) {
|
||||
const lang = this.mailer.getUserLanguage(recipientId);
|
||||
const lang = await this.mailer.getUserLanguage(recipientId);
|
||||
const { title, body } = getEventText(lang, event, params);
|
||||
const msg: ChannelMessage = {
|
||||
event,
|
||||
@@ -815,9 +817,14 @@ export class NotificationsService {
|
||||
// One send per channel, over the admin's own credentials, not per-recipient.
|
||||
// Always rendered in English — there is no single recipient to take a language from.
|
||||
if (scope === 'admin') {
|
||||
const globalChannels = channels.filter(
|
||||
ch => ch.sendGlobal && ch.supportsEvent(event) && isAdminGlobalChannel(ch.id) && this.prefs.getAdminGlobalPref(event, ch.id),
|
||||
);
|
||||
// R1.4: `filter` cannot await, and the admin global preference is now an
|
||||
// async read. Same channels, same order, one explicit loop.
|
||||
const globalChannels: ExternalChannel[] = [];
|
||||
for (const ch of channels) {
|
||||
if (!ch.sendGlobal || !ch.supportsEvent(event) || !isAdminGlobalChannel(ch.id)) continue;
|
||||
if (!(await this.prefs.getAdminGlobalPref(event, ch.id))) continue;
|
||||
globalChannels.push(ch);
|
||||
}
|
||||
if (globalChannels.length > 0) {
|
||||
const { title, body } = getEventText('en', event, params);
|
||||
const msg: ChannelMessage = { event, title, body, navigateTarget: navigateTarget ?? undefined, url: fullLink };
|
||||
|
||||
@@ -26,18 +26,18 @@ export class ReminderJobsService implements OnApplicationBootstrap {
|
||||
private readonly registrar: CronRegistrarService,
|
||||
) {}
|
||||
|
||||
private getSetting(key: string): string | undefined {
|
||||
private async getSetting(key: string): Promise<string | undefined> {
|
||||
return this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value;
|
||||
}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
if (!this.registrar.isEnabled()) return;
|
||||
|
||||
// Boot banners only — the enable gates are read per tick below; these
|
||||
// reflect the state at boot.
|
||||
try {
|
||||
const reminderEnabled = this.getSetting('notify_trip_reminder') !== 'false';
|
||||
const channelsRaw = this.getSetting('notification_channels') || this.getSetting('notification_channel') || 'none';
|
||||
const reminderEnabled = (await this.getSetting('notify_trip_reminder')) !== 'false';
|
||||
const channelsRaw = (await this.getSetting('notification_channels')) || (await this.getSetting('notification_channel')) || 'none';
|
||||
const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map(c => c.trim());
|
||||
if (!reminderEnabled) {
|
||||
logInfo('Trip reminders: disabled in settings');
|
||||
@@ -46,7 +46,7 @@ export class ReminderJobsService implements OnApplicationBootstrap {
|
||||
logInfo(`Trip reminders: enabled via [${activeChannels.join(',')}]${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`);
|
||||
}
|
||||
|
||||
if (this.getSetting('notify_todo_due') !== 'false') {
|
||||
if ((await this.getSetting('notify_todo_due')) !== 'false') {
|
||||
logInfo(`Todo due reminders: enabled (lead ${TODO_REMINDER_LEAD_DAYS}d)`);
|
||||
} else {
|
||||
logInfo('Todo due reminders: disabled in settings');
|
||||
@@ -62,7 +62,7 @@ export class ReminderJobsService implements OnApplicationBootstrap {
|
||||
/** Daily check for trips starting exactly reminder_days from now. */
|
||||
async tripTick(): Promise<void> {
|
||||
try {
|
||||
if (this.getSetting('notify_trip_reminder') === 'false') return;
|
||||
if ((await this.getSetting('notify_trip_reminder')) === 'false') return;
|
||||
|
||||
const trips = this.db.all<{ id: number; title: string; user_id: number; reminder_days: number }>(`
|
||||
SELECT t.id, t.title, t.user_id, t.reminder_days FROM trips t
|
||||
@@ -86,7 +86,7 @@ export class ReminderJobsService implements OnApplicationBootstrap {
|
||||
/** Daily check for unchecked todos due inside the lead window. */
|
||||
async todoTick(): Promise<void> {
|
||||
try {
|
||||
if (this.getSetting('notify_todo_due') === 'false') return;
|
||||
if ((await this.getSetting('notify_todo_due')) === 'false') return;
|
||||
|
||||
// Select unchecked todos with a due date inside the lead window
|
||||
// that haven't been reminded in the last 24 hours. `due_date` is
|
||||
|
||||
@@ -109,11 +109,11 @@ function encodeHeaderValue(value: string): string {
|
||||
export class NtfyService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
private getAppSetting(key: string): string | null {
|
||||
private async getAppSetting(key: string): Promise<string | null> {
|
||||
return this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value || null;
|
||||
}
|
||||
|
||||
getUserNtfyConfig(userId: number): NtfyConfig | null {
|
||||
async getUserNtfyConfig(userId: number): Promise<NtfyConfig | null> {
|
||||
const rows = this.db.all<{ key: string; value: string }>(
|
||||
"SELECT key, value FROM settings WHERE user_id = ? AND key IN ('ntfy_topic', 'ntfy_server', 'ntfy_token')",
|
||||
userId,
|
||||
@@ -128,10 +128,10 @@ export class NtfyService {
|
||||
};
|
||||
}
|
||||
|
||||
getAdminNtfyConfig(): NtfyConfig {
|
||||
const topic = this.getAppSetting('admin_ntfy_topic') || null;
|
||||
const server = this.getAppSetting('admin_ntfy_server') || null;
|
||||
const rawToken = this.getAppSetting('admin_ntfy_token') || null;
|
||||
async getAdminNtfyConfig(): Promise<NtfyConfig> {
|
||||
const topic = (await this.getAppSetting('admin_ntfy_topic')) || null;
|
||||
const server = (await this.getAppSetting('admin_ntfy_server')) || null;
|
||||
const rawToken = (await this.getAppSetting('admin_ntfy_token')) || null;
|
||||
return {
|
||||
topic,
|
||||
server,
|
||||
@@ -197,7 +197,7 @@ export class NtfyService {
|
||||
server?: string | null;
|
||||
token?: string | null;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const adminCfg = this.getAdminNtfyConfig();
|
||||
const adminCfg = await this.getAdminNtfyConfig();
|
||||
const url = resolveNtfyUrl(adminCfg, { topic: cfg.topic, server: cfg.server ?? null, token: cfg.token ?? null });
|
||||
if (!url) return { success: false, error: 'Could not resolve ntfy URL — missing topic' };
|
||||
try {
|
||||
|
||||
@@ -46,14 +46,14 @@ export function buildWebhookBody(
|
||||
export class WebhookService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
getUserWebhookUrl(userId: number): string | null {
|
||||
async getUserWebhookUrl(userId: number): Promise<string | null> {
|
||||
const value = this.db.get<{ value: string }>(
|
||||
"SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'", userId,
|
||||
)?.value || null;
|
||||
return value ? decrypt_api_key(value) : null;
|
||||
}
|
||||
|
||||
getAdminWebhookUrl(): string | null {
|
||||
async getAdminWebhookUrl(): Promise<string | null> {
|
||||
const value = this.db.get<{ value: string }>(
|
||||
'SELECT value FROM app_settings WHERE key = ?', 'admin_webhook_url',
|
||||
)?.value || null;
|
||||
|
||||
@@ -93,7 +93,7 @@ export class PlacePhotoCacheService {
|
||||
return { photoUrl: this.proxyUrl(placeId), attribution: row.attribution };
|
||||
}
|
||||
|
||||
getErrored(placeId: string): boolean {
|
||||
async getErrored(placeId: string): Promise<boolean> {
|
||||
const failedAt = this.recentFailures.get(placeId);
|
||||
if (failedAt !== undefined) {
|
||||
if (Date.now() - failedAt < FAILURE_TTL) return true;
|
||||
@@ -114,7 +114,7 @@ export class PlacePhotoCacheService {
|
||||
* provider has an image for this place — and is persisted; 'provider-error' is a
|
||||
* failed attempt and is only held in memory for a few minutes.
|
||||
*/
|
||||
markError(placeId: string, kind: 'no-photo' | 'provider-error' = 'no-photo'): void {
|
||||
async markError(placeId: string, kind: 'no-photo' | 'provider-error' = 'no-photo'): Promise<void> {
|
||||
if (kind === 'provider-error') {
|
||||
if (this.recentFailures.size >= FAILURE_SWEEP_AT) {
|
||||
const cutoff = Date.now() - FAILURE_TTL;
|
||||
@@ -201,7 +201,7 @@ export class PlacePhotoCacheService {
|
||||
* table — otherwise the nightly sweep + trip-place delete would evict a photo
|
||||
* still shown on a collection thumbnail (#1081 photo-cache pitfall).
|
||||
*/
|
||||
private isReferenced(placeId: string): boolean {
|
||||
private async isReferenced(placeId: string): Promise<boolean> {
|
||||
const row = this.db.get(
|
||||
`SELECT 1 FROM places WHERE google_place_id = ? OR image_url = ?
|
||||
UNION ALL
|
||||
@@ -225,7 +225,7 @@ export class PlacePhotoCacheService {
|
||||
* for prompt reclamation; the nightly sweep is the catch-all for every other path.
|
||||
*/
|
||||
async removeIfUnreferenced(placeId: string): Promise<void> {
|
||||
if (this.isReferenced(placeId)) return;
|
||||
if (await this.isReferenced(placeId)) return;
|
||||
await this.deleteEntry(placeId);
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ export class PlacePhotoCacheService {
|
||||
const rows = this.db.all<{ place_id: string }>('SELECT place_id FROM google_place_photo_meta');
|
||||
const keepFiles = new Set<string>();
|
||||
for (const { place_id } of rows) {
|
||||
if (this.isReferenced(place_id)) {
|
||||
if (await this.isReferenced(place_id)) {
|
||||
keepFiles.add(this.fileName(place_id));
|
||||
} else {
|
||||
await this.deleteEntry(place_id);
|
||||
|
||||
@@ -243,7 +243,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
new AtlasMcp(new AtlasService(dbService, await createTestUnitOfWork(dbService.connection)), addonsService, authService),
|
||||
new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain, new SettingsService(dbService, await createTestUnitOfWork(dbService.connection))), addonsService, authService, captureBackfill),
|
||||
new MemoriesMcp(immichService, synologyService, dbService, addonsService),
|
||||
new NotificationsMcp(makeNotificationsService(dbService, realtimeService), authService),
|
||||
new NotificationsMcp(await makeNotificationsService(dbService, realtimeService), authService),
|
||||
new AirtrailMcp(new AirtrailService(dbService, new AuditService(dbService), new AirtrailClient()), addonsService),
|
||||
new ReservationImportMcp(new AirtrailImportService(dbService, realtimeService, reservationsService, new AirtrailClient(), new AirtrailService(dbService, new AuditService(dbService), new AirtrailClient())), dbService, authService, guards, addonsService),
|
||||
new SettingsMcp(new SettingsService(dbService, await createTestUnitOfWork(dbService.connection)), authService),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { NotificationPreferencesService } from '../../src/nest/notifications/not
|
||||
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
|
||||
import { NtfyService } from '../../src/nest/notifications/transports/ntfy.service';
|
||||
import { WebhookService } from '../../src/nest/notifications/transports/webhook.service';
|
||||
import { createTestUnitOfWork } from './test-uow';
|
||||
|
||||
/**
|
||||
* A NotificationsService wired the way Nest wires it.
|
||||
@@ -13,21 +14,23 @@ import { WebhookService } from '../../src/nest/notifications/transports/webhook.
|
||||
* it by hand — every added constructor parameter was an eight-file diff. One
|
||||
* helper keeps that at one.
|
||||
*/
|
||||
export function makeNotificationsService(dbs: DatabaseService, realtime = new RealtimeService()): NotificationsService {
|
||||
export async function makeNotificationsService(dbs: DatabaseService, realtime = new RealtimeService()): Promise<NotificationsService> {
|
||||
const mailer = new MailerService(dbs);
|
||||
const uow = await createTestUnitOfWork(dbs.connection);
|
||||
return new NotificationsService(
|
||||
dbs,
|
||||
realtime,
|
||||
mailer,
|
||||
new WebhookService(dbs),
|
||||
new NtfyService(dbs),
|
||||
new NotificationPreferencesService(dbs, mailer),
|
||||
new NotificationPreferencesService(dbs, mailer, uow),
|
||||
uow,
|
||||
);
|
||||
}
|
||||
|
||||
/** The preferences half on its own, over the same connection. */
|
||||
export function makeNotificationPreferencesService(dbs: DatabaseService): NotificationPreferencesService {
|
||||
return new NotificationPreferencesService(dbs, new MailerService(dbs));
|
||||
export async function makeNotificationPreferencesService(dbs: DatabaseService): Promise<NotificationPreferencesService> {
|
||||
return new NotificationPreferencesService(dbs, new MailerService(dbs), await createTestUnitOfWork(dbs.connection));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -107,7 +107,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
|
||||
const dayNotes = new DayNotesService(dbs, permissions, realtime);
|
||||
const assignments = new AssignmentsService(dbs, permissions, realtime, queryHelpers, journey);
|
||||
const membership = new TripMembershipService(dbs);
|
||||
const notifications = makeNotificationsService(dbs, realtime);
|
||||
const notifications = await makeNotificationsService(dbs, realtime);
|
||||
const llmConfig = new LlmConfigResolver(new SettingsService(dbs, await createTestUnitOfWork(dbs.connection)), dbs, addons);
|
||||
const oauth = new PluginOAuthService(dbs);
|
||||
const accommodations = new AccommodationsService(dbs, permissions, realtime, assignments, await createTestUnitOfWork(dbs.connection));
|
||||
|
||||
@@ -109,7 +109,7 @@ beforeAll(async () => {
|
||||
new PasskeyService(dbs, auth, webauthn),
|
||||
auth,
|
||||
permissions,
|
||||
makeNotificationsService(dbs, realtime),
|
||||
await makeNotificationsService(dbs, realtime),
|
||||
userCleanup,
|
||||
realtime,
|
||||
);
|
||||
|
||||
@@ -87,7 +87,7 @@ beforeAll(async () => {
|
||||
new PasskeyService(dbs, auth, webauthn),
|
||||
auth,
|
||||
permissions,
|
||||
makeNotificationsService(dbs, realtime),
|
||||
await makeNotificationsService(dbs, realtime),
|
||||
userCleanup,
|
||||
realtime,
|
||||
);
|
||||
|
||||
@@ -53,12 +53,14 @@ import { createUser, createAdmin, setAppSetting, setNotificationChannels } from
|
||||
// (notifications.instance.ts died with the last cycle-dodge bridge); a
|
||||
// hand-constructed instance still shares the module-scoped channel registry,
|
||||
// which is exactly what CHOVR-015 pins.
|
||||
import type { NotificationPayload } from '../../../src/nest/notifications/notifications.service';
|
||||
import type { NotificationPayload, NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
||||
import { makeNotificationsService } from '../../helpers/notifications';
|
||||
|
||||
// One instance, built at module load like the old import-time singleton: its
|
||||
// constructor is what registers the built-in channels the registry cases read.
|
||||
const notifications = makeNotificationsService(new DatabaseService(testDb));
|
||||
// Built in beforeAll: the service now takes a UnitOfWork, which is async to build.
|
||||
// Its constructor is still what registers the built-in channels the registry cases read.
|
||||
let notifications: NotificationsService;
|
||||
const send = (payload: NotificationPayload) => notifications.send(payload);
|
||||
import { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service';
|
||||
import { WebhookService } from '../../../src/nest/notifications/transports/webhook.service';
|
||||
@@ -66,6 +68,7 @@ import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { createPluginRuntime } from '../../helpers/plugin-host';
|
||||
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
|
||||
import { NotificationPreferencesService } from '../../../src/nest/notifications/notification-preferences.service';
|
||||
import { createTestUnitOfWork } from '../../helpers/test-uow';
|
||||
|
||||
import {
|
||||
setPluginChannelSource,
|
||||
@@ -77,7 +80,7 @@ import {
|
||||
import type { ExternalChannel } from '../../../src/nest/notifications/notification-events';
|
||||
|
||||
const prefsDbs = new DatabaseService(testDb);
|
||||
const prefsSvc = new NotificationPreferencesService(prefsDbs, new MailerService(prefsDbs));
|
||||
let prefsSvc: NotificationPreferencesService;
|
||||
// An arrow forwarder rather than `.bind(prefsSvc)`: under `strictBindCallApply: false`
|
||||
// a bound alias is typed `any`, which hides a missing `await` from tsc and the lint
|
||||
// rules alike (recipe R4).
|
||||
@@ -85,7 +88,12 @@ const getPreferencesMatrix = (...a: Parameters<NotificationPreferencesService['g
|
||||
prefsSvc.getPreferencesMatrix(...a);
|
||||
void WebhookService; void NtfyService;
|
||||
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
prefsSvc = new NotificationPreferencesService(prefsDbs, new MailerService(prefsDbs), await createTestUnitOfWork(testDb));
|
||||
notifications = await makeNotificationsService(new DatabaseService(testDb));
|
||||
});
|
||||
beforeEach(() => { resetTestDb(testDb); setPluginChannelSource(null); });
|
||||
|
||||
const rogueSend = vi.fn().mockResolvedValue(true);
|
||||
|
||||
@@ -43,11 +43,13 @@ import { registerBuiltinChannels } from '../../../src/nest/notifications/channel
|
||||
import { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service';
|
||||
import { WebhookService } from '../../../src/nest/notifications/transports/webhook.service';
|
||||
import { __resetChannelsForTest } from '../../../src/nest/notifications/channel-registry';
|
||||
import { createTestUnitOfWork } from '../../helpers/test-uow';
|
||||
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const mailer = new MailerService(dbs);
|
||||
registerBuiltinChannels({ mailer, webhook: new WebhookService(dbs), ntfy: new NtfyService(dbs) });
|
||||
const svc = new NotificationPreferencesService(dbs, mailer);
|
||||
// Constructed in beforeAll: the service now takes a UnitOfWork, which is async to build.
|
||||
let svc: NotificationPreferencesService;
|
||||
|
||||
// Legacy free-function names bound to the service, so the moved cases read as before.
|
||||
// Arrow forwarders rather than `.bind(svc)`: under `strictBindCallApply: false` a bound
|
||||
@@ -64,9 +66,10 @@ const isSmtpConfigured = (...a: Parameters<Svc['isSmtpConfigured']>) => svc.isSm
|
||||
const isWebhookConfigured = (...a: Parameters<Svc['isWebhookConfigured']>) => svc.isWebhookConfigured(...a);
|
||||
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
svc = new NotificationPreferencesService(dbs, mailer, await createTestUnitOfWork(testDb));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -82,23 +85,23 @@ afterAll(() => {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('isEnabledForEvent', () => {
|
||||
it('NPREF-001 — returns true when no row exists (default enabled)', () => {
|
||||
it('NPREF-001 — returns true when no row exists (default enabled)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
|
||||
expect(await isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-002 — returns true when row exists with enabled=1', () => {
|
||||
it('NPREF-002 — returns true when row exists with enabled=1', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare(
|
||||
'INSERT INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 1)'
|
||||
).run(user.id, 'trip_invite', 'email');
|
||||
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
|
||||
expect(await isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-003 — returns false when row exists with enabled=0', () => {
|
||||
it('NPREF-003 — returns false when row exists with enabled=0', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
|
||||
expect(await isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,9 +192,9 @@ describe('getPreferencesMatrix', () => {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('setPreferences', () => {
|
||||
it('NPREF-012 — disabling a preference inserts a row with enabled=0', () => {
|
||||
it('NPREF-012 — disabling a preference inserts a row with enabled=0', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setPreferences(user.id, { trip_invite: { email: false } });
|
||||
await setPreferences(user.id, { trip_invite: { email: false } });
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(user.id, 'trip_invite', 'email') as { enabled: number } | undefined;
|
||||
@@ -199,12 +202,12 @@ describe('setPreferences', () => {
|
||||
expect(row!.enabled).toBe(0);
|
||||
});
|
||||
|
||||
it('NPREF-013 — re-enabling a preference removes the disabled row', () => {
|
||||
it('NPREF-013 — re-enabling a preference removes the disabled row', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// First disable
|
||||
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||
// Then re-enable
|
||||
setPreferences(user.id, { trip_invite: { email: true } });
|
||||
await setPreferences(user.id, { trip_invite: { email: true } });
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(user.id, 'trip_invite', 'email');
|
||||
@@ -212,16 +215,16 @@ describe('setPreferences', () => {
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('NPREF-014 — bulk update handles multiple event+channel combos', () => {
|
||||
it('NPREF-014 — bulk update handles multiple event+channel combos', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setPreferences(user.id, {
|
||||
await setPreferences(user.id, {
|
||||
trip_invite: { email: false, webhook: false },
|
||||
booking_change: { email: false },
|
||||
trip_reminder: { webhook: true },
|
||||
});
|
||||
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
|
||||
expect(isEnabledForEvent(user.id, 'trip_invite', 'webhook')).toBe(false);
|
||||
expect(isEnabledForEvent(user.id, 'booking_change', 'email')).toBe(false);
|
||||
expect(await isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
|
||||
expect(await isEnabledForEvent(user.id, 'trip_invite', 'webhook')).toBe(false);
|
||||
expect(await isEnabledForEvent(user.id, 'booking_change', 'email')).toBe(false);
|
||||
// trip_reminder webhook was set to true → no row, default enabled
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
@@ -263,9 +266,9 @@ describe('getActiveChannels', () => {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('channel availability', () => {
|
||||
it('NPREF-019 — detects SMTP config from app_settings.smtp_host', () => {
|
||||
it('NPREF-019 — detects SMTP config from app_settings.smtp_host', async () => {
|
||||
setAppSetting(testDb, 'smtp_host', 'mail.example.com');
|
||||
expect(isSmtpConfigured()).toBe(true);
|
||||
expect(await isSmtpConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-020 — webhook is active when admin has enabled the webhook channel', async () => {
|
||||
@@ -273,11 +276,11 @@ describe('channel availability', () => {
|
||||
expect(await getActiveChannels()).toContain('webhook');
|
||||
});
|
||||
|
||||
it('NPREF-021 — detects SMTP config from env var SMTP_HOST', () => {
|
||||
it('NPREF-021 — detects SMTP config from env var SMTP_HOST', async () => {
|
||||
const original = process.env.SMTP_HOST;
|
||||
process.env.SMTP_HOST = 'env-mail.example.com';
|
||||
try {
|
||||
expect(isSmtpConfigured()).toBe(true);
|
||||
expect(await isSmtpConfigured()).toBe(true);
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.SMTP_HOST;
|
||||
else process.env.SMTP_HOST = original;
|
||||
@@ -296,17 +299,17 @@ describe('channel availability', () => {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('setAdminPreferences', () => {
|
||||
it('NPREF-022 — disabling email for version_available stores global pref in app_settings', () => {
|
||||
it('NPREF-022 — disabling email for version_available stores global pref in app_settings', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
setAdminPreferences(user.id, { version_available: { email: false } });
|
||||
expect(getAdminGlobalPref('version_available', 'email')).toBe(false);
|
||||
await setAdminPreferences(user.id, { version_available: { email: false } });
|
||||
expect(await getAdminGlobalPref('version_available', 'email')).toBe(false);
|
||||
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined;
|
||||
expect(row?.value).toBe('0');
|
||||
});
|
||||
|
||||
it('NPREF-023 — disabling inapp for version_available stores per-user row in notification_channel_preferences', () => {
|
||||
it('NPREF-023 — disabling inapp for version_available stores per-user row in notification_channel_preferences', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
setAdminPreferences(user.id, { version_available: { inapp: false } });
|
||||
await setAdminPreferences(user.id, { version_available: { inapp: false } });
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(user.id, 'version_available', 'inapp') as { enabled: number } | undefined;
|
||||
@@ -317,24 +320,24 @@ describe('setAdminPreferences', () => {
|
||||
expect(globalRow).toBeUndefined();
|
||||
});
|
||||
|
||||
it('NPREF-024 — re-enabling inapp removes the disabled per-user row', () => {
|
||||
it('NPREF-024 — re-enabling inapp removes the disabled per-user row', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
// First disable
|
||||
disableNotificationPref(testDb, user.id, 'version_available', 'inapp');
|
||||
// Then re-enable via setAdminPreferences
|
||||
setAdminPreferences(user.id, { version_available: { inapp: true } });
|
||||
await setAdminPreferences(user.id, { version_available: { inapp: true } });
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(user.id, 'version_available', 'inapp');
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('NPREF-025 — enabling email stores global pref as "1" in app_settings', () => {
|
||||
it('NPREF-025 — enabling email stores global pref as "1" in app_settings', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
// First disable, then re-enable
|
||||
setAdminPreferences(user.id, { version_available: { email: false } });
|
||||
setAdminPreferences(user.id, { version_available: { email: true } });
|
||||
expect(getAdminGlobalPref('version_available', 'email')).toBe(true);
|
||||
await setAdminPreferences(user.id, { version_available: { email: false } });
|
||||
await setAdminPreferences(user.id, { version_available: { email: true } });
|
||||
expect(await getAdminGlobalPref('version_available', 'email')).toBe(true);
|
||||
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined;
|
||||
expect(row?.value).toBe('1');
|
||||
});
|
||||
|
||||
@@ -32,11 +32,11 @@ describe('NotificationsController (parity with the legacy /api/notifications rou
|
||||
expect(getPreferences).toHaveBeenCalledWith(4, 'user');
|
||||
});
|
||||
|
||||
it('PUT saves then returns the refreshed matrix', () => {
|
||||
it('PUT saves then returns the refreshed matrix', async () => {
|
||||
const setPreferences = vi.fn();
|
||||
const getPreferences = vi.fn().mockReturnValue({ preferences: { a: { inapp: true } } });
|
||||
const body = { a: { inapp: true } };
|
||||
expect(makeController({ setPreferences, getPreferences }).setPreferences(user, body)).toEqual({ preferences: { a: { inapp: true } } });
|
||||
expect(await makeController({ setPreferences, getPreferences }).setPreferences(user, body)).toEqual({ preferences: { a: { inapp: true } } });
|
||||
expect(setPreferences).toHaveBeenCalledWith(4, body);
|
||||
});
|
||||
});
|
||||
@@ -141,33 +141,33 @@ describe('NotificationsController (parity with the legacy /api/notifications rou
|
||||
});
|
||||
|
||||
describe('in-app list + counts', () => {
|
||||
it('clamps limit to 50 and defaults offset/unread', () => {
|
||||
it('clamps limit to 50 and defaults offset/unread', async () => {
|
||||
const listInApp = vi.fn().mockReturnValue({ notifications: [], total: 0, unread_count: 0 });
|
||||
makeController({ listInApp }).listInApp(user, '100', '5', 'true');
|
||||
await makeController({ listInApp }).listInApp(user, '100', '5', 'true');
|
||||
expect(listInApp).toHaveBeenCalledWith(4, { limit: 50, offset: 5, unreadOnly: true });
|
||||
});
|
||||
|
||||
it('defaults limit to 20 when absent/non-numeric', () => {
|
||||
it('defaults limit to 20 when absent/non-numeric', async () => {
|
||||
const listInApp = vi.fn().mockReturnValue({ notifications: [], total: 0, unread_count: 0 });
|
||||
makeController({ listInApp }).listInApp(user, undefined, undefined, undefined);
|
||||
await makeController({ listInApp }).listInApp(user, undefined, undefined, undefined);
|
||||
expect(listInApp).toHaveBeenCalledWith(4, { limit: 20, offset: 0, unreadOnly: false });
|
||||
});
|
||||
|
||||
it('GET unread-count wraps the number', () => {
|
||||
it('GET unread-count wraps the number', async () => {
|
||||
const unreadCount = vi.fn().mockReturnValue(7);
|
||||
expect(makeController({ unreadCount }).unreadCount(user)).toEqual({ count: 7 });
|
||||
expect(await makeController({ unreadCount }).unreadCount(user)).toEqual({ count: 7 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulk + single mutations', () => {
|
||||
it('read-all returns success + count', () => {
|
||||
it('read-all returns success + count', async () => {
|
||||
const markAllRead = vi.fn().mockReturnValue(3);
|
||||
expect(makeController({ markAllRead }).readAll(user)).toEqual({ success: true, count: 3 });
|
||||
expect(await makeController({ markAllRead }).readAll(user)).toEqual({ success: true, count: 3 });
|
||||
});
|
||||
|
||||
it('delete-all returns success + count', () => {
|
||||
it('delete-all returns success + count', async () => {
|
||||
const deleteAll = vi.fn().mockReturnValue(5);
|
||||
expect(makeController({ deleteAll }).deleteAll(user)).toEqual({ success: true, count: 5 });
|
||||
expect(await makeController({ deleteAll }).deleteAll(user)).toEqual({ success: true, count: 5 });
|
||||
});
|
||||
|
||||
it('400 on a non-numeric id', () => {
|
||||
@@ -183,15 +183,15 @@ describe('NotificationsController (parity with the legacy /api/notifications rou
|
||||
});
|
||||
});
|
||||
|
||||
it('mark-read success', () => {
|
||||
it('mark-read success', async () => {
|
||||
const markRead = vi.fn().mockReturnValue(true);
|
||||
expect(makeController({ markRead }).markRead(user, '5')).toEqual({ success: true });
|
||||
expect(await makeController({ markRead }).markRead(user, '5')).toEqual({ success: true });
|
||||
expect(markRead).toHaveBeenCalledWith(5, 4);
|
||||
});
|
||||
|
||||
it('delete single success', () => {
|
||||
it('delete single success', async () => {
|
||||
const deleteOne = vi.fn().mockReturnValue(true);
|
||||
expect(makeController({ deleteOne }).deleteOne(user, '5')).toEqual({ success: true });
|
||||
expect(await makeController({ deleteOne }).deleteOne(user, '5')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -47,14 +47,20 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
||||
import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications';
|
||||
|
||||
const notifications = makeNotificationsService(new DatabaseService(testDb));
|
||||
const createNotification = notifications.createNotification.bind(notifications);
|
||||
const createNotificationForRecipient = notifications.createNotificationForRecipient.bind(notifications);
|
||||
const respondToBoolean = notifications.respond.bind(notifications);
|
||||
// Built in beforeAll: the service now takes a UnitOfWork, which is async to build.
|
||||
let notifications: NotificationsService;
|
||||
// Arrow forwarders rather than `.bind(notifications)`: under `strictBindCallApply: false`
|
||||
// a bound alias is typed `any`, which hides a missing `await` from tsc AND from the
|
||||
// type-aware lint rules (recipe R4).
|
||||
type Svc = NotificationsService;
|
||||
const createNotification = (...a: Parameters<Svc['createNotification']>) => notifications.createNotification(...a);
|
||||
const createNotificationForRecipient = (...a: Parameters<Svc['createNotificationForRecipient']>) => notifications.createNotificationForRecipient(...a);
|
||||
const respondToBoolean = (...a: Parameters<Svc['respond']>) => notifications.respond(...a);
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
notifications = await makeNotificationsService(new DatabaseService(testDb));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -71,14 +77,14 @@ afterAll(() => {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createNotification — preference filtering', () => {
|
||||
it('INOTIF-001 — notification without event_type is delivered to all recipients (backward compat)', () => {
|
||||
it('INOTIF-001 — notification without event_type is delivered to all recipients (backward compat)', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const { user: recipient } = createUser(testDb);
|
||||
// The admin scope targets all admins — create a second admin as the sender
|
||||
const { user: sender } = createAdmin(testDb);
|
||||
|
||||
// Send to a specific user (user scope) without event_type
|
||||
const ids = createNotification({
|
||||
const ids = await createNotification({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: recipient.id,
|
||||
@@ -94,7 +100,7 @@ describe('createNotification — preference filtering', () => {
|
||||
// Also verify the admin who disabled all prefs still gets messages without event_type
|
||||
disableNotificationPref(testDb, admin.id, 'trip_invite', 'inapp');
|
||||
// admin still gets this since no event_type check
|
||||
const adminIds = createNotification({
|
||||
const adminIds = await createNotification({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: admin.id,
|
||||
@@ -105,7 +111,7 @@ describe('createNotification — preference filtering', () => {
|
||||
expect(adminIds.length).toBe(1);
|
||||
});
|
||||
|
||||
it('INOTIF-002 — notification with event_type skips recipients who have disabled that event on inapp', () => {
|
||||
it('INOTIF-002 — notification with event_type skips recipients who have disabled that event on inapp', async () => {
|
||||
const { user: sender } = createAdmin(testDb);
|
||||
const { user: recipient1 } = createUser(testDb);
|
||||
const { user: recipient2 } = createUser(testDb);
|
||||
@@ -118,7 +124,7 @@ describe('createNotification — preference filtering', () => {
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient1.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient2.id);
|
||||
|
||||
const ids = createNotification({
|
||||
const ids = await createNotification({
|
||||
type: 'simple',
|
||||
scope: 'trip',
|
||||
target: tripId,
|
||||
@@ -136,12 +142,12 @@ describe('createNotification — preference filtering', () => {
|
||||
expect(r2).toBeUndefined();
|
||||
});
|
||||
|
||||
it('INOTIF-003 — notification with event_type delivers to recipients with no stored preferences', () => {
|
||||
it('INOTIF-003 — notification with event_type delivers to recipients with no stored preferences', async () => {
|
||||
const { user: sender } = createAdmin(testDb);
|
||||
const { user: recipient } = createUser(testDb);
|
||||
|
||||
// No preferences stored for recipient — should default to enabled
|
||||
const ids = createNotification({
|
||||
const ids = await createNotification({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: recipient.id,
|
||||
@@ -156,11 +162,11 @@ describe('createNotification — preference filtering', () => {
|
||||
expect(row).toBeDefined();
|
||||
});
|
||||
|
||||
it('INOTIF-003b — createNotificationForRecipient inserts a single notification and broadcasts via WS', () => {
|
||||
it('INOTIF-003b — createNotificationForRecipient inserts a single notification and broadcasts via WS', async () => {
|
||||
const { user: sender } = createAdmin(testDb);
|
||||
const { user: recipient } = createUser(testDb);
|
||||
|
||||
const id = createNotificationForRecipient(
|
||||
const id = await createNotificationForRecipient(
|
||||
{
|
||||
type: 'navigate',
|
||||
scope: 'user',
|
||||
@@ -185,14 +191,14 @@ describe('createNotification — preference filtering', () => {
|
||||
expect(broadcastMock.mock.calls[0][0]).toBe(recipient.id);
|
||||
});
|
||||
|
||||
it('INOTIF-004 — admin-scope version_available only reaches admins with enabled pref', () => {
|
||||
it('INOTIF-004 — admin-scope version_available only reaches admins with enabled pref', async () => {
|
||||
const { user: admin1 } = createAdmin(testDb);
|
||||
const { user: admin2 } = createAdmin(testDb);
|
||||
|
||||
// admin2 disables version_available inapp notifications
|
||||
disableNotificationPref(testDb, admin2.id, 'version_available', 'inapp');
|
||||
|
||||
const ids = createNotification({
|
||||
const ids = await createNotification({
|
||||
type: 'navigate',
|
||||
scope: 'admin',
|
||||
target: 0,
|
||||
@@ -381,10 +387,10 @@ describe('respondToBoolean', () => {
|
||||
expect(broadcastPayload?.notification.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
|
||||
});
|
||||
|
||||
it('INOTIF-014 — createNotification broadcast carries an ISO-UTC created_at (was the un-patched #1149 path)', () => {
|
||||
it('INOTIF-014 — createNotification broadcast carries an ISO-UTC created_at (was the un-patched #1149 path)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
createNotification({
|
||||
await createNotification({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: user.id,
|
||||
|
||||
@@ -101,7 +101,8 @@ import { setPluginChannelSource } from '../../../src/nest/notifications/channel-
|
||||
import type { ExternalChannel } from '../../../src/nest/notifications/notification-events';
|
||||
import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications';
|
||||
|
||||
const notifications = makeNotificationsService(new DatabaseService(testDb));
|
||||
// Built in beforeAll: the service now takes a UnitOfWork, which is async to build.
|
||||
let notifications: NotificationsService;
|
||||
const send = (payload: NotificationPayload) => notifications.send(payload);
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -138,9 +139,10 @@ function countAllNotifications(): number {
|
||||
|
||||
// ── Setup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
notifications = await makeNotificationsService(new DatabaseService(testDb));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -233,51 +233,51 @@ describe.each([
|
||||
.run(Date.now() - ms, placeId);
|
||||
}
|
||||
|
||||
it('PPC-009: a place with no photo stays remembered well past the old five-minute window', () => {
|
||||
cache.markError('photo-less');
|
||||
it('PPC-009: a place with no photo stays remembered well past the old five-minute window', async () => {
|
||||
await cache.markError('photo-less');
|
||||
|
||||
ageError('photo-less', 30 * 60 * 1000);
|
||||
expect(cache.getErrored('photo-less')).toBe(true);
|
||||
expect(await cache.getErrored('photo-less')).toBe(true);
|
||||
|
||||
ageError('photo-less', 5 * 60 * 60 * 1000);
|
||||
expect(cache.getErrored('photo-less')).toBe(true);
|
||||
expect(await cache.getErrored('photo-less')).toBe(true);
|
||||
});
|
||||
|
||||
it('PPC-010: the miss expires once it is a day old', () => {
|
||||
cache.markError('stale-miss');
|
||||
it('PPC-010: the miss expires once it is a day old', async () => {
|
||||
await cache.markError('stale-miss');
|
||||
ageError('stale-miss', 24 * 60 * 60 * 1000);
|
||||
|
||||
expect(cache.getErrored('stale-miss')).toBe(false);
|
||||
expect(await cache.getErrored('stale-miss')).toBe(false);
|
||||
});
|
||||
|
||||
// A failed provider call says nothing about the place, so it must not inherit the
|
||||
// long window a real "this place has no photo" answer gets.
|
||||
it('PPC-011: a failed provider call is forgotten after minutes and never persisted', () => {
|
||||
it('PPC-011: a failed provider call is forgotten after minutes and never persisted', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
cache.markError('flaky', 'provider-error');
|
||||
expect(cache.getErrored('flaky')).toBe(true);
|
||||
await cache.markError('flaky', 'provider-error');
|
||||
expect(await cache.getErrored('flaky')).toBe(true);
|
||||
// Nothing on disk — a restart retries instead of inheriting someone's outage.
|
||||
expect(testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('flaky')).toBeUndefined();
|
||||
|
||||
vi.advanceTimersByTime(5 * 60 * 1000);
|
||||
expect(cache.getErrored('flaky')).toBe(false);
|
||||
expect(await cache.getErrored('flaky')).toBe(false);
|
||||
|
||||
// The long window belongs to the other case: same age, still remembered.
|
||||
cache.markError('photo-less-too');
|
||||
await cache.markError('photo-less-too');
|
||||
ageError('photo-less-too', 5 * 60 * 1000);
|
||||
expect(cache.getErrored('photo-less-too')).toBe(true);
|
||||
expect(await cache.getErrored('photo-less-too')).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('PPC-012: a cached photo clears an earlier failed attempt', async () => {
|
||||
cache.markError('recovered', 'provider-error');
|
||||
expect(cache.getErrored('recovered')).toBe(true);
|
||||
await cache.markError('recovered', 'provider-error');
|
||||
expect(await cache.getErrored('recovered')).toBe(true);
|
||||
|
||||
await cache.put('recovered', await makeJpeg(40, 40), null);
|
||||
expect(cache.getErrored('recovered')).toBe(false);
|
||||
expect(await cache.getErrored('recovered')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,26 +88,26 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('ReminderJobsService bootstrap', () => {
|
||||
it('RJOB-001 — registers both 9 AM crons under their names', () => {
|
||||
it('RJOB-001 — registers both 9 AM crons under their names', async () => {
|
||||
const { svc, registered } = makeJobs();
|
||||
svc.onApplicationBootstrap();
|
||||
await svc.onApplicationBootstrap();
|
||||
expect(registered.map(r => [r.name, r.expr])).toEqual([
|
||||
['trip-reminders', '0 9 * * *'],
|
||||
['todo-reminders', '0 9 * * *'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('RJOB-002 — does nothing under the test gate (no crons, no banners)', () => {
|
||||
it('RJOB-002 — does nothing under the test gate (no crons, no banners)', async () => {
|
||||
const { svc, registered, registrar } = makeJobs();
|
||||
registrar.isEnabled.mockReturnValue(false);
|
||||
svc.onApplicationBootstrap();
|
||||
await svc.onApplicationBootstrap();
|
||||
expect(registered).toHaveLength(0);
|
||||
expect(logMock.logInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('RJOB-003 — logs the enabled banners by default and the disabled ones when toggled off', () => {
|
||||
it('RJOB-003 — logs the enabled banners by default and the disabled ones when toggled off', async () => {
|
||||
const { svc } = makeJobs();
|
||||
svc.onApplicationBootstrap();
|
||||
await svc.onApplicationBootstrap();
|
||||
expect(logMock.logInfo).toHaveBeenCalledWith('Trip reminders: enabled via []');
|
||||
expect(logMock.logInfo).toHaveBeenCalledWith('Todo due reminders: enabled (lead 3d)');
|
||||
|
||||
@@ -115,17 +115,17 @@ describe('ReminderJobsService bootstrap', () => {
|
||||
setAppSetting(testDb, 'notify_trip_reminder', 'false');
|
||||
setAppSetting(testDb, 'notify_todo_due', 'false');
|
||||
const { svc: svc2 } = makeJobs();
|
||||
svc2.onApplicationBootstrap();
|
||||
await svc2.onApplicationBootstrap();
|
||||
expect(logMock.logInfo).toHaveBeenCalledWith('Trip reminders: disabled in settings');
|
||||
expect(logMock.logInfo).toHaveBeenCalledWith('Todo due reminders: disabled in settings');
|
||||
});
|
||||
|
||||
it('RJOB-004 — the trip banner carries the active channels and the reminder-trip count', () => {
|
||||
it('RJOB-004 — the trip banner carries the active channels and the reminder-trip count', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'email');
|
||||
tripWithReminder(user.id, 5);
|
||||
const { svc } = makeJobs();
|
||||
svc.onApplicationBootstrap();
|
||||
await svc.onApplicationBootstrap();
|
||||
expect(logMock.logInfo).toHaveBeenCalledWith('Trip reminders: enabled via [email], 1 trip(s) with active reminders');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user