refactor(notifications, place-photos, backup): async DB-touching methods; transactions through UnitOfWork

This commit is contained in:
jubnl
2026-09-22 00:22:11 +02:00
parent fac181f45b
commit 4df2d83137
24 changed files with 285 additions and 250 deletions
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -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),
+7 -4
View File
@@ -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));
}
/**
+1 -1
View File
@@ -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));
+1 -1
View File
@@ -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');
});
});