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);