refactor(notifications): in-app notifications and channel preferences through repositories

This commit is contained in:
jubnl
2026-09-23 20:33:07 +02:00
parent f47a7d4568
commit 4258f05926
12 changed files with 879 additions and 143 deletions
@@ -1,4 +1,59 @@
import type { NotificationChannelPreferences } from '../entities/NotificationChannelPreferences.entity';
import { TrekRepository } from './_shared/trek-repository';
export class NotificationChannelPreferencesRepository extends TrekRepository<NotificationChannelPreferences> {}
/** NP3's projection — `event_type, channel, enabled` (no need for the `user`/`user_id` PK columns; the caller already knows which user it asked for). */
export interface NotificationChannelPreferenceRow {
event_type: string;
channel: string;
enabled: number;
}
/**
* `notification_channel_preferences` — per-user, per-event, per-channel
* opt-outs. A genuine THREE-column composite primary key (`user` relation +
* `event_type` text + `channel` text, all `.primary()` — R5/§14's "wider
* than any prior plan's composite-PK precedent" finding): every write here
* goes through `em.upsert`/`nativeDelete` targeting all three columns as the
* conflict/filter key, never a synthetic `id`. `user_id` is a `persist(false)`
* mirror of the `user` relation (the program-wide trap) — filtered/written
* through the `user` relation property throughout, never the mirror.
*/
export class NotificationChannelPreferencesRepository extends TrekRepository<NotificationChannelPreferences> {
/** NP2 — `SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?`. `enabled` is not a persist(false) mirror, so `fields` narrowing is exact. */
async findEnabled(userId: number, eventType: string, channel: string): Promise<{ enabled: number } | null> {
return await this.findOne({ user: userId, event_type: eventType, channel }, { fields: ['enabled'] });
}
/** NP3 — `SELECT event_type, channel, enabled FROM notification_channel_preferences WHERE user_id = ?` (`getPreferencesMatrix`'s full per-user override set, merged with defaults in the service). */
async listForUser(userId: number): Promise<NotificationChannelPreferenceRow[]> {
return await this.find({ user: userId }, { fields: ['event_type', 'channel', 'enabled'] });
}
/**
* NP5 — `applyUserChannelPrefs`'s `INSERT OR REPLACE INTO
* notification_channel_preferences (user_id, event_type, channel, enabled)
* VALUES (?, ?, ?, ?)`, run once per explicitly-disabled preference. The
* composite-PK `em.upsert` shape Task 0 (R5) pinned against the migration
* DDL (`user_id, event_type, channel` PRIMARY KEY) and worked an example
* for — `onConflictFields` names the THREE primary-key properties by their
* entity property names (`user`, not the persist(false) `user_id` mirror),
* and `onConflictAction: 'merge'` reproduces `OR REPLACE`'s "overwrite the
* existing row" semantics (the `enabled` column is the only non-key column,
* so there is nothing REPLACE resets that merge wouldn't also set).
* Rendered SQL pinned directly (not assumed from the shape alone) by
* `NotificationChannelPreferences.repository.test.ts`'s
* `NCPREPO-UPSERT-SQL` case — the FIRST such pin in the program (Task 6/
* `UserNoticeDismissalsRepository` re-checks against this one, per R5).
*/
async upsertPreference(userId: number, eventType: string, channel: string, enabled: number): Promise<void> {
await this.upsert(
{ user: userId, event_type: eventType, channel, enabled },
{ onConflictFields: ['user', 'event_type', 'channel'], onConflictAction: 'merge' },
);
}
/** NP6 — `applyUserChannelPrefs`'s `DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?` (removing the row is how "enabled (default)" is represented — never a write of `enabled = 1`). */
async deletePreference(userId: number, eventType: string, channel: string): Promise<number> {
return await this.nativeDelete({ user: userId, event_type: eventType, channel });
}
}
@@ -1,4 +1,259 @@
import type { Notifications } from '../entities/Notifications.entity';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
export class NotificationsRepository extends TrekRepository<Notifications> {}
/**
* A bare `notifications` row — every scalar column of the entity, incl. the
* two `persist(false)` relation mirrors (`sender_id`, `recipient_id` — the
* program-wide trap: a bare wildcard/`fields:`-narrowed ORM read silently
* drops these, so every full-row read below goes through Kysely with an
* explicit column list instead).
*/
export interface NotificationRow {
id: number;
type: string;
scope: string;
target: number;
sender_id: number | null;
recipient_id: number;
title_key: string;
title_params: string | null;
text_key: string;
text_params: string | null;
positive_text_key: string | null;
negative_text_key: string | null;
positive_callback: string | null;
negative_callback: string | null;
response: string | null;
navigate_text_key: string | null;
navigate_target: string | null;
is_read: number | null;
created_at: string | null;
}
const _notificationRowKeys: AssertRowKeys<NotificationRow, Notifications> = true;
/** NT10/NT22's joined shape — `n.*, u.username AS sender_username, u.avatar AS sender_avatar`. */
export interface NotificationJoinRow extends NotificationRow {
sender_username: string | null;
sender_avatar: string | null;
}
/** The 15-column insert shape NT5/NT8 share (`id`/`response`/`is_read`/`created_at` are autoincrement/defaulted, matching the legacy column list). */
export interface NotificationInsertRow {
type: string;
scope: string;
target: number;
sender_id: number | null;
recipient_id: number;
title_key: string;
title_params: string;
text_key: string;
text_params: string;
positive_text_key: string | null;
negative_text_key: string | null;
positive_callback: string | null;
negative_callback: string | null;
navigate_text_key: string | null;
navigate_target: string | null;
}
/**
* The minimal cross-table read shapes this repository needs for
* `resolveRecipients`/sender-info lookups (NT1/NT2/NT3/NT4/NT6/NT23/NT24).
* `trips`/`trip_members`/`users` are other plans' tables (3c/3b) — reached
* here via Kysely the same way `CollabMessagesRepository`/`BudgetItemsRepository`
* read `users`/`trip_files` for their own domain-specific joins, rather than
* a new cross-domain `@InjectRepository`, which would widen this module's
* `MikroOrmModule.forFeature` registration (and every OTHER module that
* constructs `NotificationsService`, per the BOOT GATE note) for reads that
* are each a single, narrow, already-indexed lookup.
*/
interface NotificationsKyselyDB {
notifications: NotificationRow;
users: { id: number; username: string; avatar: string | null; is_guest: number; role: string };
trips: { id: number; user_id: number };
trip_members: { trip_id: number; user_id: number };
}
/**
* `notifications` — the in-app notification store. Kysely throughout for
* `notifications` itself: `sender_id`/`recipient_id` are `persist(false)`
* mirrors (the `TripFilesRepository`/`BudgetItemsRepository` class docstring
* trap). The generic ORM helpers (`count`/`nativeUpdate`/`nativeDelete`/
* `insert`) are used wherever the statement doesn't need a full-row
* projection, since `is_read`/`response`/the relation properties themselves
* are ordinary (non-mirror) columns.
*/
export class NotificationsRepository extends TrekRepository<Notifications> {
private joinedQuery() {
return this.kysely<NotificationsKyselyDB>()
.selectFrom('notifications as n')
.leftJoin('users as u', 'u.id', 'n.sender_id')
.selectAll('n')
.select(['u.username as sender_username', 'u.avatar as sender_avatar']);
}
// ── Recipient resolution (NT1-NT4) ─────────────────────────────────────
/** NT1 — `SELECT user_id FROM trips WHERE id = ?` (the trip owner). */
async getTripOwnerId(tripId: number): Promise<number | null> {
const row = await this.kysely<NotificationsKyselyDB>()
.selectFrom('trips')
.select('user_id')
.where('id', '=', tripId)
.executeTakeFirst();
return row?.user_id ?? null;
}
/**
* NT2 — the guest-exclusion chokepoint (#1362): `SELECT m.user_id FROM
* trip_members m JOIN users u ON u.id = m.user_id WHERE m.trip_id = ? AND
* COALESCE(u.is_guest, 0) = 0`. `is_guest` is a non-nullable
* `p.integer().default(0)` column on `Users` (never actually NULL), so a
* plain `= 0` reproduces the legacy `COALESCE(..., 0) = 0` result set
* exactly — the COALESCE was defensive, not load-bearing on this schema.
* This is THE single chokepoint for in-app/email/webhook/ntfy recipient
* resolution across the whole app (the service's own doc comment) —
* preserved byte-exact, not "helpfully" folded into a relation walk.
*/
async listNonGuestTripMemberIds(tripId: number): Promise<number[]> {
const rows = await this.kysely<NotificationsKyselyDB>()
.selectFrom('trip_members as m')
.innerJoin('users as u', 'u.id', 'm.user_id')
.select('m.user_id')
.where('m.trip_id', '=', tripId)
.where('u.is_guest', '=', 0)
.execute();
return rows.map((r) => r.user_id);
}
/** NT3 — `SELECT is_guest FROM users WHERE id = ?` (a guest todo-assignee, scope='user', is never notified). */
async findGuestFlag(userId: number): Promise<{ is_guest: number } | undefined> {
return await this.kysely<NotificationsKyselyDB>()
.selectFrom('users')
.select('is_guest')
.where('id', '=', userId)
.executeTakeFirst();
}
/** NT4 — `SELECT id FROM users WHERE role = ? AND COALESCE(is_guest, 0) = 0` — same non-nullable-column reasoning as `listNonGuestTripMemberIds`. */
async listNonGuestUserIdsByRole(role: string): Promise<number[]> {
const rows = await this.kysely<NotificationsKyselyDB>()
.selectFrom('users')
.select('id')
.where('role', '=', role)
.where('is_guest', '=', 0)
.execute();
return rows.map((r) => r.id);
}
/** NT6/NT23/NT24 — `SELECT username, avatar FROM users WHERE id = ?` (sender info for WS payloads and the dev-fallback self-notification), three identical-text call sites folded onto one method. */
async findUserBasic(userId: number): Promise<{ username: string; avatar: string | null } | undefined> {
return await this.kysely<NotificationsKyselyDB>()
.selectFrom('users')
.select(['username', 'avatar'])
.where('id', '=', userId)
.executeTakeFirst();
}
// ── Notification CRUD ───────────────────────────────────────────────────
/** NT5/NT8 — the 15-column insert, shared by `createNotification`'s per-recipient loop and `createNotificationForRecipient`'s single insert. Returns the new row's id. */
async insertNotification(row: NotificationInsertRow): Promise<number> {
return await this.insert({
type: row.type,
scope: row.scope,
target: row.target,
sender: row.sender_id,
recipient: row.recipient_id,
title_key: row.title_key,
title_params: row.title_params,
text_key: row.text_key,
text_params: row.text_params,
positive_text_key: row.positive_text_key,
negative_text_key: row.negative_text_key,
positive_callback: row.positive_callback,
negative_callback: row.negative_callback,
navigate_text_key: row.navigate_text_key,
navigate_target: row.navigate_target,
});
}
/** NT7/NT9/NT19 — `SELECT * FROM notifications WHERE id = ?`, full row incl. the persist(false) mirrors. */
async findById(id: number): Promise<NotificationRow | undefined> {
return await this.kysely<NotificationsKyselyDB>().selectFrom('notifications').selectAll().where('id', '=', id).executeTakeFirst();
}
/** NT19 — `respond`'s recipient-scoped initial read: `SELECT * FROM notifications WHERE id = ? AND recipient_id = ?`. */
async findByIdForRecipient(id: number, recipientId: number): Promise<NotificationRow | undefined> {
return await this.kysely<NotificationsKyselyDB>()
.selectFrom('notifications')
.selectAll()
.where('id', '=', id)
.where('recipient_id', '=', recipientId)
.executeTakeFirst();
}
/** NT10 — `listInApp`'s joined page read, `unreadOnly` toggling the extra `is_read = 0` predicate (the legacy statement's two dynamic-WHERE variants). */
async listForRecipient(recipientId: number, limit: number, offset: number, unreadOnly: boolean): Promise<NotificationJoinRow[]> {
let q = this.joinedQuery().where('n.recipient_id', '=', recipientId);
if (unreadOnly) q = q.where('n.is_read', '=', 0);
return await q.orderBy('n.created_at', 'desc').limit(limit).offset(offset).execute();
}
/** NT22 — `respond`'s post-update re-select: the same joined shape, `WHERE n.id = ?`, deliberately unscoped by recipient (the id was already recipient-scope-verified by `findByIdForRecipient` earlier in the same call). */
async findWithSenderById(id: number): Promise<NotificationJoinRow | undefined> {
return await this.joinedQuery().where('n.id', '=', id).executeTakeFirst();
}
/** NT11/NT12 — `listInApp`'s `total`/`unread_count` reads. Neither `recipient`(relation)/`is_read` is a persist(false) mirror, so the generic `count()` helper is exact. */
async countForRecipient(recipientId: number, unreadOnly: boolean): Promise<number> {
return await this.count(unreadOnly ? { recipient: recipientId, is_read: 0 } : { recipient: recipientId });
}
/** NT12/NT13 — `SELECT COUNT(*) ... WHERE recipient_id = ? AND is_read = 0`, the same predicate `listInApp`'s `unread_count` and the standalone `unreadCount` method both need. */
async countUnreadForRecipient(recipientId: number): Promise<number> {
return await this.count({ recipient: recipientId, is_read: 0 });
}
/** NT14/NT15 — `markRead`/`markUnread`: `UPDATE notifications SET is_read = ? WHERE id = ? AND recipient_id = ?`. Returns the affected-row count (0 or 1). */
async setRead(id: number, recipientId: number, isRead: 0 | 1): Promise<number> {
return await this.nativeUpdate({ id, recipient: recipientId }, { is_read: isRead });
}
/** NT16 — `markAllRead`: `UPDATE notifications SET is_read = 1 WHERE recipient_id = ? AND is_read = 0`. */
async markAllRead(recipientId: number): Promise<number> {
return await this.nativeUpdate({ recipient: recipientId, is_read: 0 }, { is_read: 1 });
}
/** NT17 — `deleteOne`: `DELETE FROM notifications WHERE id = ? AND recipient_id = ?`. */
async deleteForRecipient(id: number, recipientId: number): Promise<number> {
return await this.nativeDelete({ id, recipient: recipientId });
}
/** NT18 — `deleteAll`: `DELETE FROM notifications WHERE recipient_id = ?`. */
async deleteAllForRecipient(recipientId: number): Promise<number> {
return await this.nativeDelete({ recipient: recipientId });
}
/**
* NT20 — `respond`'s atomic claim, preserved byte-exact: `UPDATE
* notifications SET response = ?, is_read = 1 WHERE id = ? AND
* recipient_id = ? AND response IS NULL`. `response: null` in the filter
* compiles to `response IS NULL` (MikroORM's standard null-filter
* semantics) — only updates, and only returns a nonzero count, if no
* concurrent responder has already claimed this notification. This is the
* security-critical double-submit fix (R8): claim BEFORE the handler runs,
* so a concurrent second call's UPDATE affects 0 rows and can never
* execute the callback action a second time.
*/
async claimResponse(id: number, recipientId: number, response: string): Promise<number> {
return await this.nativeUpdate({ id, recipient: recipientId, response: null }, { response, is_read: 1 });
}
/** NT21 — `respond`'s handler-failure release: `UPDATE notifications SET response = NULL, is_read = ? WHERE id = ? AND recipient_id = ?`, restoring the claim so the user can retry. */
async releaseResponse(id: number, recipientId: number, isRead: number | null): Promise<void> {
await this.nativeUpdate({ id, recipient: recipientId }, { response: null, is_read: isRead });
}
}
@@ -1,5 +1,9 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { AppSettings } from '../../db/entities/AppSettings.entity';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import { NotificationChannelPreferences } from '../../db/entities/NotificationChannelPreferences.entity';
import type { NotificationChannelPreferencesRepository } from '../../db/repositories/NotificationChannelPreferences.repository';
import { UnitOfWork } from '../database/unit-of-work';
import { MailerService } from './mailer/mailer.service';
import { listChannels } from './channel-registry';
@@ -30,19 +34,27 @@ export interface PreferencesMatrix {
*
* It reads the live channel set from the registry but never sends anything —
* the dispatch loop in NotificationsService applies what is decided here.
*
* DI-native (Plan 3f Task 3): the `SELECT value FROM app_settings WHERE
* key = ?`/`INSERT OR REPLACE INTO app_settings ...` reads/writes (NP1/NP4)
* moved onto the shared `AppSettingsRepository.getValue`/`.setValue` pair
* (already built, Plan 3a) rather than a new method — this is the first of
* six identical `app_settings` single-key sites in this cluster to converge
* on it (plan3f-sql-inventory.md §15 surprise 8). `notification_channel_preferences`'s
* reads/writes (NP2/NP3/NP5/NP6) go through `NotificationChannelPreferencesRepository`,
* whose `upsertPreference`/`deletePreference` target the table's genuine
* 3-column composite primary key (`user`, `event_type`, `channel`) via
* `em.upsert({ onConflictFields: [...] })`, per Task 0's R5 pin.
*/
@Injectable()
export class NotificationPreferencesService {
constructor(
private readonly db: DatabaseService,
private readonly mailer: MailerService,
private readonly uow: UnitOfWork,
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
@InjectRepository(NotificationChannelPreferences) private readonly channelPrefs: NotificationChannelPreferencesRepository,
) {}
private async getAppSetting(key: string): Promise<string | null> {
return this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value || null;
}
/**
* Channels implemented for an event. In-app takes everything; external channels
* decide for themselves (today: everything except `synology_session_cleared`).
@@ -71,7 +83,7 @@ export class NotificationPreferencesService {
* would silently drop any that were).
*/
async getActiveChannels(): Promise<NotifChannel[]> {
const raw = (await this.getAppSetting('notification_channels')) || (await this.getAppSetting('notification_channel')) || 'none';
const raw = (await this.appSettings.getValue('notification_channels')) || (await this.appSettings.getValue('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));
@@ -89,11 +101,8 @@ export class NotificationPreferencesService {
* Default (no row) = enabled. Only returns false if there's an explicit disabled row.
*/
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,
);
return row === undefined || row.enabled === 1;
const row = await this.channelPrefs.findEnabled(userId, eventType, channel);
return row === null || row.enabled === 1;
}
// ── Preferences matrix ─────────────────────────────────────────────────────
@@ -121,8 +130,8 @@ export class NotificationPreferencesService {
// Admin-scoped events go out over the admin's own global credentials, which
// are independent of the per-user `notification_channels` toggle.
const hasSmtp = await this.mailer.isSmtpConfigured();
const hasAdminWebhook = !!(await this.getAppSetting('admin_webhook_url'));
const hasAdminNtfy = !!(await this.getAppSetting('admin_ntfy_topic'));
const hasAdminWebhook = !!(await this.appSettings.getValue('admin_webhook_url'));
const hasAdminNtfy = !!(await this.appSettings.getValue('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.
@@ -162,9 +171,7 @@ export class NotificationPreferencesService {
* scope='admin' — returns only admin-scoped events (for admin notifications tab)
*/
async getPreferencesMatrix(userId: number, userRole: string, scope: 'user' | 'admin' = 'user'): Promise<PreferencesMatrix> {
const rows = this.db.all<{ event_type: string; channel: string; enabled: number }>(
'SELECT event_type, channel, enabled FROM notification_channel_preferences WHERE user_id = ?', userId,
);
const rows = await this.channelPrefs.listForUser(userId);
// Build a lookup from stored rows
const stored: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
@@ -200,7 +207,7 @@ export class NotificationPreferencesService {
channels: await this.describeChannels(userId, scope),
event_types,
implemented_combos,
...(scope === 'user' && { defaults: { ntfyServer: (await this.getAppSetting('admin_ntfy_server')) || null } }),
...(scope === 'user' && { defaults: { ntfyServer: (await this.appSettings.getValue('admin_ntfy_server')) || null } }),
};
}
@@ -212,15 +219,12 @@ export class NotificationPreferencesService {
* Defaults to true (enabled) when no row exists.
*/
async getAdminGlobalPref(event: NotifEventType, channel: AdminGlobalChannel): Promise<boolean> {
const val = await this.getAppSetting(`admin_notif_pref_${event}_${channel}`);
const val = await this.appSettings.getValue(`admin_notif_pref_${event}_${channel}`);
return val !== '0';
}
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',
);
await this.appSettings.setValue(`admin_notif_pref_${event}_${channel}`, enabled ? '1' : '0');
}
// ── Preferences update ─────────────────────────────────────────────────────
@@ -230,20 +234,14 @@ export class NotificationPreferencesService {
userId: number,
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>,
): Promise<void> {
const upsert = this.db.prepare(
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
);
const del = this.db.prepare(
'DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
);
for (const [eventType, channels] of Object.entries(prefs)) {
if (!channels) continue;
for (const [channel, enabled] of Object.entries(channels)) {
if (enabled) {
// Remove explicit row — default is enabled
del.run(userId, eventType, channel);
await this.channelPrefs.deletePreference(userId, eventType, channel);
} else {
upsert.run(userId, eventType, channel, 0);
await this.channelPrefs.upsertPreference(userId, eventType, channel, 0);
}
}
}
@@ -288,7 +286,11 @@ export class NotificationPreferencesService {
}
}
// Apply global prefs outside the transaction (they write to app_settings)
// Apply global prefs outside the transaction (they write to app_settings).
// Mixed atomicity, preserved as-is (plan3f-sql-inventory.md §4b's flag, R10):
// these are single-key upserts with no cross-row invariant, so a partial
// failure here is low-risk — widening this into the transaction below is a
// ruling this task does not make on its own.
for (const [eventType, channels] of Object.entries(globalPrefs)) {
if (!channels) continue;
for (const [channel, enabled] of Object.entries(channels)) {
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { AdminNotificationPreferencesController, NotificationsController } from './notifications.controller';
import { NotificationsMcp } from './notifications.mcp';
import { NotificationsService } from './notifications.service';
@@ -11,6 +12,9 @@ import { MailerModule } from './mailer/mailer.module';
import { AuthModule } from '../auth/auth.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { StorageModule } from '../storage/storage.module';
import { Notifications } from '../../db/entities/Notifications.entity';
import { NotificationChannelPreferences } from '../../db/entities/NotificationChannelPreferences.entity';
import { AppSettings } from '../../db/entities/AppSettings.entity';
/** Notifications domain (L6 leaf module). Registered in AppModule.
* AuthModule feeds NotificationsMcp's demo gate; MailerModule carries SMTP,
@@ -21,9 +25,17 @@ import { StorageModule } from '../storage/storage.module';
* imports only AppConfig+Audit+Scheduling).
* NotificationsService and NotificationPreferencesService are exported for
* in-container consumers (AdminController's dev test send and preferences tab,
* the plugin RPC surface, HostSurfaceRpc). */
* the plugin RPC surface, HostSurfaceRpc).
* MikroOrmModule.forFeature([Notifications, NotificationChannelPreferences,
* AppSettings]) (Plan 3f Task 3) registers NotificationsRepository/
* NotificationChannelPreferencesRepository/AppSettingsRepository for
* NotificationsService/NotificationPreferencesService's @InjectRepository
* constructor params — the TodoModule/PermissionsModule precedent. Only
* this module needs the registration: NotificationsService/
* NotificationPreferencesService are providers here alone, every other
* module injects the exported singleton rather than declaring its own. */
@Module({
imports: [AuthModule, MailerModule, SchedulingModule, StorageModule],
imports: [MikroOrmModule.forFeature([Notifications, NotificationChannelPreferences, AppSettings]), AuthModule, MailerModule, SchedulingModule, StorageModule],
controllers: [NotificationsController, AdminNotificationPreferencesController],
providers: [
NotificationsService,
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import type { ChannelTestResult } from '@trek/shared';
import { readEnv, getAppUrl } from '../../app-config';
import { logDebug, logError } from '../audit/audit-log.logger';
@@ -18,7 +19,8 @@ import {
import { getChannel, listChannels } from './channel-registry';
import { getAction } from './in-app-actions';
import { avatarUrl } from '../common/avatarUrl';
import { DatabaseService } from '../database/database.service';
import { Notifications } from '../../db/entities/Notifications.entity';
import type { NotificationsRepository } from '../../db/repositories/Notifications.repository';
import { UnitOfWork } from '../database/unit-of-work';
import { RealtimeService } from '../realtime/realtime.service';
@@ -76,9 +78,9 @@ interface NotificationRow {
sender_avatar?: string | null;
recipient_id: number;
title_key: string;
title_params: string;
title_params: string | null;
text_key: string;
text_params: string;
text_params: string | null;
positive_text_key: string | null;
negative_text_key: string | null;
positive_callback: string | null;
@@ -86,7 +88,7 @@ interface NotificationRow {
response: NotificationResponse | null;
navigate_text_key: string | null;
navigate_target: string | null;
is_read: number;
is_read: number | null;
created_at: string;
}
@@ -311,13 +313,13 @@ async function shouldSendToUser(
@Injectable()
export class NotificationsService {
constructor(
private readonly db: DatabaseService,
private readonly realtime: RealtimeService,
private readonly mailer: MailerService,
private readonly webhook: WebhookService,
private readonly ntfy: NtfyService,
private readonly prefs: NotificationPreferencesService,
private readonly uow: UnitOfWork,
@InjectRepository(Notifications) private readonly notificationsRepo: NotificationsRepository,
) {
// The registry is a module singleton shared with the plugin runtime and any
// separately-constructed instance (which never runs onModuleInit), so
@@ -384,19 +386,18 @@ export class NotificationsService {
// so they must never be resolved as notification recipients on any scope. This is the
// single chokepoint for in-app/email/webhook/ntfy, so filtering here covers all channels.
if (scope === 'trip') {
const owner = this.db.get<{ user_id: number }>('SELECT user_id FROM trips WHERE id = ?', target);
const members = this.db.all<{ user_id: number }>('SELECT m.user_id FROM trip_members m JOIN users u ON u.id = m.user_id WHERE m.trip_id = ? AND COALESCE(u.is_guest, 0) = 0', target);
const ownerId = await this.notificationsRepo.getTripOwnerId(target);
const memberIds = await this.notificationsRepo.listNonGuestTripMemberIds(target);
const ids = new Set<number>();
if (owner) ids.add(owner.user_id);
for (const m of members) ids.add(m.user_id);
if (ownerId != null) ids.add(ownerId);
for (const id of memberIds) ids.add(id);
userIds = Array.from(ids);
} else if (scope === 'user') {
// A guest can be a todo assignee (scope='user'); never notify them.
const u = this.db.get<{ is_guest?: number }>('SELECT is_guest FROM users WHERE id = ?', target);
const u = await this.notificationsRepo.findGuestFlag(target);
userIds = u && u.is_guest ? [] : [target];
} else if (scope === 'admin') {
const admins = this.db.all<{ id: number }>("SELECT id FROM users WHERE role = ? AND COALESCE(is_guest, 0) = 0", 'admin');
userIds = admins.map(a => a.id);
userIds = await this.notificationsRepo.listNonGuestUserIdsByRole('admin');
}
// Only exclude sender for group scopes (trip/admin) — for user scope, the target is explicit
@@ -423,15 +424,6 @@ export class NotificationsService {
const insertedPairs: Array<{ id: number; recipientId: number }> = [];
await this.uow.transactional(async () => {
const stmt = this.db.prepare(`
INSERT INTO notifications (
type, scope, target, sender_id, recipient_id,
title_key, title_params, text_key, text_params,
positive_text_key, negative_text_key, positive_callback, negative_callback,
navigate_text_key, navigate_target
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const recipientId of recipients) {
// Check per-user in-app preference if an event_type is provided
if (input.event_type && !(await this.prefs.isEnabledForEvent(recipientId, input.event_type, 'inapp'))) {
@@ -455,34 +447,32 @@ export class NotificationsService {
navigateTarget = input.navigate_target;
}
const result = stmt.run(
input.type, input.scope, input.target, input.sender_id, recipientId,
input.title_key, titleParams, input.text_key, textParams,
positiveTextKey, negativeTextKey, positiveCallback, negativeCallback,
navigateTextKey, navigateTarget
);
const id = await this.notificationsRepo.insertNotification({
type: input.type, scope: input.scope, target: input.target, sender_id: input.sender_id, recipient_id: recipientId,
title_key: input.title_key, title_params: titleParams, text_key: input.text_key, text_params: textParams,
positive_text_key: positiveTextKey, negative_text_key: negativeTextKey, positive_callback: positiveCallback, negative_callback: negativeCallback,
navigate_text_key: navigateTextKey, navigate_target: navigateTarget,
});
insertedPairs.push({ id: result.lastInsertRowid as number, recipientId });
insertedPairs.push({ id, recipientId });
}
});
// Fetch sender info once for WS payloads
const sender = input.sender_id
? this.db.get<{ username: string; avatar: string | null }>('SELECT username, avatar FROM users WHERE id = ?', input.sender_id)
: null;
const sender = input.sender_id ? await this.notificationsRepo.findUserBasic(input.sender_id) : undefined;
// Broadcast to each recipient
for (const { id: notificationId, recipientId } of insertedPairs) {
const row = this.db.get<NotificationRow>('SELECT * FROM notifications WHERE id = ?', notificationId) as NotificationRow;
const row = await this.notificationsRepo.findById(notificationId);
if (!row) continue;
this.realtime.broadcastToUser(recipientId, {
type: 'notification:new',
notification: {
...row,
created_at: toUtcIso(row.created_at),
created_at: toUtcIso(row.created_at!),
sender_username: sender?.username ?? null,
sender_avatar: avatarUrl({ avatar: sender?.avatar }),
sender_avatar: avatarUrl({ avatar: sender?.avatar ?? null }),
},
});
}
@@ -519,31 +509,23 @@ export class NotificationsService {
navigateTarget = input.navigate_target;
}
const result = this.db.run(`
INSERT INTO notifications (
type, scope, target, sender_id, recipient_id,
title_key, title_params, text_key, text_params,
positive_text_key, negative_text_key, positive_callback, negative_callback,
navigate_text_key, navigate_target
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
input.type, input.scope, input.target, input.sender_id, recipientId,
input.title_key, titleParams, input.text_key, textParams,
positiveTextKey, negativeTextKey, positiveCallback, negativeCallback,
navigateTextKey, navigateTarget
);
const notificationId = await this.notificationsRepo.insertNotification({
type: input.type, scope: input.scope, target: input.target, sender_id: input.sender_id, recipient_id: recipientId,
title_key: input.title_key, title_params: titleParams, text_key: input.text_key, text_params: textParams,
positive_text_key: positiveTextKey, negative_text_key: negativeTextKey, positive_callback: positiveCallback, negative_callback: negativeCallback,
navigate_text_key: navigateTextKey, navigate_target: navigateTarget,
});
const notificationId = result.lastInsertRowid as number;
const row = this.db.get<NotificationRow>('SELECT * FROM notifications WHERE id = ?', notificationId);
const row = await this.notificationsRepo.findById(notificationId);
if (!row) return null;
this.realtime.broadcastToUser(recipientId, {
type: 'notification:new',
notification: {
...row,
created_at: toUtcIso(row.created_at),
created_at: toUtcIso(row.created_at!),
sender_username: sender?.username ?? null,
sender_avatar: avatarUrl({ avatar: sender?.avatar }),
sender_avatar: avatarUrl({ avatar: sender?.avatar ?? null }),
},
});
@@ -560,58 +542,46 @@ export class NotificationsService {
const offset = options.offset ?? 0;
const unreadOnly = options.unreadOnly ?? false;
const whereAliased = unreadOnly ? 'WHERE n.recipient_id = ? AND n.is_read = 0' : 'WHERE n.recipient_id = ?';
const wherePlain = unreadOnly ? 'WHERE recipient_id = ? AND is_read = 0' : 'WHERE recipient_id = ?';
const rows = this.db.all<NotificationRow>(`
SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar
FROM notifications n
LEFT JOIN users u ON n.sender_id = u.id
${whereAliased}
ORDER BY n.created_at DESC
LIMIT ? OFFSET ?
`, userId, limit, offset);
const { total } = this.db.get<{ total: number }>(`SELECT COUNT(*) as total FROM notifications ${wherePlain}`, userId) as { total: number };
const { unread_count } = this.db.get<{ unread_count: number }>('SELECT COUNT(*) as unread_count FROM notifications WHERE recipient_id = ? AND is_read = 0', userId) as { unread_count: number };
const rows = await this.notificationsRepo.listForRecipient(userId, limit, offset, unreadOnly);
const total = await this.notificationsRepo.countForRecipient(userId, unreadOnly);
const unread_count = await this.notificationsRepo.countUnreadForRecipient(userId);
// The repository row's `type`/`scope`/`response` are the physical TEXT
// columns (plain `string`); this service's own `NotificationRow` narrows
// them to the literal unions it writes — the same unchecked boundary the
// legacy `db.all<NotificationRow>(...)` generic drew (SQLite has no enum
// type to check against either way).
const mapped = rows.map(r => ({
...r,
created_at: toUtcIso(r.created_at),
created_at: toUtcIso(r.created_at!),
sender_avatar: avatarUrl({ avatar: r.sender_avatar }),
}));
})) as NotificationRow[];
return { notifications: mapped, total, unread_count };
}
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;
return await this.notificationsRepo.countUnreadForRecipient(userId);
}
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;
return (await this.notificationsRepo.setRead(notificationId, userId, 1)) > 0;
}
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;
return (await this.notificationsRepo.setRead(notificationId, userId, 0)) > 0;
}
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;
return await this.notificationsRepo.markAllRead(userId);
}
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;
return (await this.notificationsRepo.deleteForRecipient(notificationId, userId)) > 0;
}
async deleteAll(userId: number): Promise<number> {
const result = this.db.run('DELETE FROM notifications WHERE recipient_id = ?', userId);
return result.changes;
return await this.notificationsRepo.deleteAllForRecipient(userId);
}
async respond(
@@ -619,7 +589,7 @@ export class NotificationsService {
userId: number,
response: NotificationResponse
): Promise<RespondResult> {
const notification = this.db.get<NotificationRow>('SELECT * FROM notifications WHERE id = ? AND recipient_id = ?', notificationId, userId);
const notification = await this.notificationsRepo.findByIdForRecipient(notificationId, userId);
if (!notification) return { success: false, error: 'Notification not found' };
if (notification.type !== 'boolean') return { success: false, error: 'Not a boolean notification' };
@@ -641,37 +611,27 @@ export class NotificationsService {
// Atomic claim BEFORE the handler runs — only updates if response is still
// NULL, so a concurrent double-submit can never execute the action twice
// (the legacy order ran the handler first, letting both submits through).
const result = this.db.run(
'UPDATE notifications SET response = ?, is_read = 1 WHERE id = ? AND recipient_id = ? AND response IS NULL',
response, notificationId, userId
);
const changes = await this.notificationsRepo.claimResponse(notificationId, userId, response);
if (result.changes === 0) return { success: false, error: 'Already responded' };
if (changes === 0) return { success: false, error: 'Already responded' };
try {
await handler(callback.payload, userId);
} catch (err) {
// Release the claim so the user can retry — legacy contract: a handler
// failure returns its message and leaves the notification unresponded.
this.db.run(
'UPDATE notifications SET response = NULL, is_read = ? WHERE id = ? AND recipient_id = ?',
notification.is_read, notificationId, userId
);
await this.notificationsRepo.releaseResponse(notificationId, userId, notification.is_read);
return { success: false, error: err instanceof Error ? err.message : 'Action failed' };
}
const updated = this.db.get<NotificationRow>(`
SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar
FROM notifications n
LEFT JOIN users u ON n.sender_id = u.id
WHERE n.id = ?
`, notificationId) as NotificationRow;
const updated = (await this.notificationsRepo.findWithSenderById(notificationId))!;
// Same unchecked-narrowing boundary as `listInApp`'s `mapped` above.
const mappedUpdated = {
...updated,
created_at: toUtcIso(updated.created_at),
created_at: toUtcIso(updated.created_at!),
sender_avatar: avatarUrl({ avatar: updated.sender_avatar }),
};
} as NotificationRow;
this.realtime.broadcastToUser(userId, { type: 'notification:updated', notification: mappedUpdated });
@@ -691,7 +651,7 @@ export class NotificationsService {
if (!configEntry) {
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;
const devSender = (await this.notificationsRepo.findUserBasic(actorId)) ?? null;
await this.createNotificationForRecipient({
type: 'simple',
scope: 'user',
@@ -718,7 +678,7 @@ export class NotificationsService {
// Fetch sender info once for in-app WS payloads
const sender = actorId
? this.db.get<{ username: string; avatar: string | null }>('SELECT username, avatar FROM users WHERE id = ?', actorId) ?? null
? (await this.notificationsRepo.findUserBasic(actorId)) ?? null
: null;
logDebug(`notificationService.send event=${event} scope=${scope} targetId=${targetId} recipients=${recipients.length} channels=inapp,${activeChannels.join(',')}`);
@@ -0,0 +1,26 @@
import type Database from 'better-sqlite3';
import { sharedTestOrm } from './test-uow';
import { Notifications } from '../../src/db/entities/Notifications.entity';
import type { NotificationsRepository } from '../../src/db/repositories/Notifications.repository';
import { NotificationChannelPreferences } from '../../src/db/entities/NotificationChannelPreferences.entity';
import type { NotificationChannelPreferencesRepository } from '../../src/db/repositories/NotificationChannelPreferences.repository';
/**
* Plan 3f Task 3 (`NotificationsService`/`NotificationPreferencesService`)
* test-only repository factories, bound to a suite's own better-sqlite3
* handle via the SAME memoised `sharedTestOrm` `test-uow.ts` exports — the
* `todo-repos.ts`/`packing-repos.ts` precedent (a repository from a SECOND,
* independent ORM instance would resolve a different transaction fork than
* the `UnitOfWork`/`AppSettingsRepository` a hand-built service also needs).
*
* A NEW file rather than an addition to `test-uow.ts`, same reasoning those
* two files give for themselves: avoids colliding with a concurrently
* in-flight task's edits to that shared file.
*/
export function createTestNotificationsRepo(db: Database.Database): Promise<NotificationsRepository> {
return sharedTestOrm(db).then((t) => t.repo(Notifications));
}
export function createTestNotificationChannelPreferencesRepo(db: Database.Database): Promise<NotificationChannelPreferencesRepository> {
return sharedTestOrm(db).then((t) => t.repo(NotificationChannelPreferences));
}
+19 -4
View File
@@ -5,7 +5,8 @@ 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';
import { createTestUnitOfWork, createTestAppSettingsRepo } from './test-uow';
import { createTestNotificationsRepo, createTestNotificationChannelPreferencesRepo } from './notifications-repos';
/**
* A NotificationsService wired the way Nest wires it.
@@ -13,24 +14,38 @@ import { createTestUnitOfWork } from './test-uow';
* The domain takes six providers since the fold, and eight places used to build
* it by hand — every added constructor parameter was an eight-file diff. One
* helper keeps that at one.
*
* Plan 3f Task 3: `NotificationsService`/`NotificationPreferencesService` no
* longer take a `DatabaseService` — both now take repositories, resolved
* through `notifications-repos.ts`/`test-uow.ts`'s `sharedTestOrm`-memoised
* factories, bound to `dbs.connection` the same way every other converted
* domain's test helper does. The helper's own OUTER signature (`dbs`,
* `realtime`) is unchanged, so every caller that only goes through this
* function (`mcp-test-controllers.ts`, `plugin-host.ts`, both test-only) needs
* no edit of its own.
*/
export async function makeNotificationsService(dbs: DatabaseService, realtime = new RealtimeService()): Promise<NotificationsService> {
const mailer = new MailerService(dbs);
const uow = await createTestUnitOfWork(dbs.connection);
const appSettings = await createTestAppSettingsRepo(dbs.connection);
const channelPrefsRepo = await createTestNotificationChannelPreferencesRepo(dbs.connection);
const notificationsRepo = await createTestNotificationsRepo(dbs.connection);
return new NotificationsService(
dbs,
realtime,
mailer,
new WebhookService(dbs),
new NtfyService(dbs),
new NotificationPreferencesService(dbs, mailer, uow),
new NotificationPreferencesService(mailer, uow, appSettings, channelPrefsRepo),
uow,
notificationsRepo,
);
}
/** The preferences half on its own, over the same connection. */
export async function makeNotificationPreferencesService(dbs: DatabaseService): Promise<NotificationPreferencesService> {
return new NotificationPreferencesService(dbs, new MailerService(dbs), await createTestUnitOfWork(dbs.connection));
const appSettings = await createTestAppSettingsRepo(dbs.connection);
const channelPrefsRepo = await createTestNotificationChannelPreferencesRepo(dbs.connection);
return new NotificationPreferencesService(new MailerService(dbs), await createTestUnitOfWork(dbs.connection), appSettings, channelPrefsRepo);
}
/**
@@ -0,0 +1,123 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser, type TestUser } from '../../../helpers/factories';
import { NotificationChannelPreferences } from '../../../../src/db/entities/NotificationChannelPreferences.entity';
import type { NotificationChannelPreferencesRepository } from '../../../../src/db/repositories/NotificationChannelPreferences.repository';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repo: NotificationChannelPreferencesRepository;
let user: TestUser;
let otherUser: TestUser;
beforeAll(async () => {
t = await createTestOrm(testDb);
repo = t.repo(NotificationChannelPreferences);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
user = createUser(testDb).user;
otherUser = createUser(testDb).user;
});
afterAll(async () => {
await t.close();
testDb.close();
});
function insertRaw(userId: number, eventType: string, channel: string, enabled: number): void {
testDb.prepare('INSERT INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)').run(userId, eventType, channel, enabled);
}
describe('notification_channel_preferences — the (user_id, event_type, channel) composite PK the ON CONFLICT target relies on (R5/§14)', () => {
it("NCPREPO-SCHEMA-001: PRAGMA table_info('notification_channel_preferences') reports a 3-column PRIMARY KEY over exactly (user_id, event_type, channel), matching the migration DDL", () => {
const cols = testDb.prepare("PRAGMA table_info('notification_channel_preferences')").all() as { name: string; pk: number }[];
const pkCols = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk).map((c) => c.name);
expect(pkCols).toEqual(['user_id', 'event_type', 'channel']);
});
it('NCPREPO-SCHEMA-002: the entity metadata declares the same 3-property composite PK, naming the RELATION property, not its persist(false) twin', () => {
const meta = t.orm.getMetadata(NotificationChannelPreferences);
expect(meta.getPrimaryProps().map((p) => p.name).sort()).toEqual(['channel', 'event_type', 'user'].sort());
});
});
describe('NotificationChannelPreferencesRepository — reads (NP2/NP3 parity)', () => {
it('NCPREPO-001 — findEnabled matches `SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?`, and is null for an absent row (default-enabled)', async () => {
insertRaw(user.id, 'trip_invite', 'email', 0);
const legacy = 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 };
const converted = await repo.findEnabled(user.id, 'trip_invite', 'email');
// A plain-object equality against the MikroORM-returned entity would also
// compare its (identical, but differently-shaped) relation/metadata
// fields — the field values are the parity contract here, not the
// wrapper shape (the `AppSettingsRepository.getValue` precedent).
expect(converted?.enabled).toBe(legacy.enabled);
expect(await repo.findEnabled(user.id, 'trip_invite', 'webhook')).toBeNull();
});
it('NCPREPO-002 — listForUser matches `SELECT event_type, channel, enabled FROM notification_channel_preferences WHERE user_id = ?`, fully seeded across every event×channel combination, and never leaks another user', async () => {
const combos: Array<[string, string, number]> = [
['trip_invite', 'email', 0], ['trip_invite', 'webhook', 1], ['trip_invite', 'inapp', 0],
['booking_change', 'email', 1], ['booking_change', 'ntfy', 0],
['vacay_invite', 'inapp', 0],
];
for (const [event_type, channel, enabled] of combos) insertRaw(user.id, event_type, channel, enabled);
insertRaw(otherUser.id, 'trip_invite', 'email', 0); // a different user's row — must not leak in
const legacy = (testDb.prepare('SELECT event_type, channel, enabled FROM notification_channel_preferences WHERE user_id = ?').all(user.id) as { event_type: string; channel: string; enabled: number }[])
.map((r) => ({ event_type: r.event_type, channel: r.channel, enabled: r.enabled }))
.sort((a, b) => a.event_type.localeCompare(b.event_type) || a.channel.localeCompare(b.channel));
const converted = (await repo.listForUser(user.id))
.map((r) => ({ event_type: r.event_type, channel: r.channel, enabled: r.enabled }))
.sort((a, b) => a.event_type.localeCompare(b.event_type) || a.channel.localeCompare(b.channel));
expect(converted.length).toBe(combos.length);
expect(converted).toEqual(legacy);
});
});
describe('NotificationChannelPreferencesRepository — writes (NP5/NP6, R5)', () => {
it(
"NCPREPO-UPSERT-SQL: em.upsert renders `insert into \"notification_channel_preferences\" ... on conflict (`user_id`, `event_type`, `channel`) do update set ...` — the composite-PK conflict target Task 0's R5 worked example pinned, checked directly against the rendered SQL, not assumed from onConflictFields alone",
async () => {
const connection = t.orm.em.getConnection();
const spy = vi.spyOn(connection, 'execute');
try {
// Insert branch: no existing row.
await repo.upsertPreference(user.id, 'trip_invite', 'email', 0);
expect(testDb.prepare('SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?').get(user.id, 'trip_invite', 'email')).toEqual({ enabled: 0 });
const insertSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql));
expect(insertSql).toContain('on conflict (`user_id`, `event_type`, `channel`)');
spy.mockClear();
// Merge branch: an existing row, same call shape — the SAME conflict
// target both creates and merges, matching `OR REPLACE`'s semantics
// (there is no other non-key column REPLACE would reset that merge
// does not also set).
await repo.upsertPreference(user.id, 'trip_invite', 'email', 1);
expect(testDb.prepare('SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?').get(user.id, 'trip_invite', 'email')).toEqual({ enabled: 1 });
expect((testDb.prepare('SELECT COUNT(*) as c FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?').get(user.id, 'trip_invite', 'email') as { c: number }).c).toBe(1);
const mergeSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql));
expect(mergeSql).toContain('on conflict (`user_id`, `event_type`, `channel`)');
} finally {
spy.mockRestore();
}
},
);
it('NCPREPO-003 — deletePreference matches `DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?`, scoped to exactly that triple', async () => {
insertRaw(user.id, 'trip_invite', 'email', 0);
insertRaw(user.id, 'trip_invite', 'webhook', 0); // a different channel — must survive
insertRaw(otherUser.id, 'trip_invite', 'email', 0); // a different user — must survive
expect(await repo.deletePreference(user.id, 'trip_invite', 'email')).toBe(1);
expect(testDb.prepare('SELECT * FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?').get(user.id, 'trip_invite', 'email')).toBeUndefined();
expect(testDb.prepare('SELECT * FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?').get(user.id, 'trip_invite', 'webhook')).toBeDefined();
expect(testDb.prepare('SELECT * FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?').get(otherUser.id, 'trip_invite', 'email')).toBeDefined();
expect(await repo.deletePreference(user.id, 'trip_invite', 'email')).toBe(0); // already gone — no-op
});
});
@@ -0,0 +1,258 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser, createTrip, addTripMember, type TestUser, type TestTrip } from '../../../helpers/factories';
import { Notifications } from '../../../../src/db/entities/Notifications.entity';
import type { NotificationsRepository } from '../../../../src/db/repositories/Notifications.repository';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repo: NotificationsRepository;
let owner: TestUser;
let trip: TestTrip;
beforeAll(async () => {
t = await createTestOrm(testDb);
repo = t.repo(Notifications);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
owner = createUser(testDb).user;
trip = createTrip(testDb, owner.id);
});
afterAll(async () => {
await t.close();
testDb.close();
});
/** Inserts one of each NT-inventory notification type, with every optional field set, for `recipientId`. Returns the three ids in insertion order (simple, boolean, navigate). */
function seedThreeTypes(recipientId: number, senderId: number | null): { simpleId: number; booleanId: number; navigateId: number } {
const simpleId = testDb.prepare(`
INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, title_params, text_key, text_params)
VALUES ('simple', 'trip', ?, ?, ?, 'notif.trip_invite.title', '{"trip":"Rome"}', 'notif.trip_invite.text', '{"actor":"Alice"}')
`).run(trip.id, senderId, recipientId).lastInsertRowid as number;
const booleanId = testDb.prepare(`
INSERT INTO notifications (
type, scope, target, sender_id, recipient_id, title_key, title_params, text_key, text_params,
positive_text_key, negative_text_key, positive_callback, negative_callback
) VALUES ('boolean', 'trip', ?, ?, ?, 'notif.vacay_invite.title', '{"trip":"Rome"}', 'notif.vacay_invite.text', '{"actor":"Bob"}',
'notif.action.accept', 'notif.action.decline', '{"action":"noop","payload":{"x":1}}', '{"action":"noop","payload":{"x":2}}')
`).run(trip.id, senderId, recipientId).lastInsertRowid as number;
const navigateId = testDb.prepare(`
INSERT INTO notifications (
type, scope, target, sender_id, recipient_id, title_key, title_params, text_key, text_params,
navigate_text_key, navigate_target
) VALUES ('navigate', 'trip', ?, ?, ?, 'notif.booking_change.title', '{"trip":"Rome"}', 'notif.booking_change.text', '{"actor":"Carl"}',
'notif.action.view_trip', '/trips/1')
`).run(trip.id, senderId, recipientId).lastInsertRowid as number;
return { simpleId, booleanId, navigateId };
}
/** The legacy `SELECT * FROM notifications WHERE id = ?`, run raw, for a full-key parity comparison. */
function legacyFindById(id: number): unknown {
return testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id);
}
describe('NotificationsRepository — findById/findByIdForRecipient parity (fully seeded: simple/boolean/navigate, every optional field set)', () => {
it('NOTREPO-001 — findById matches the legacy full-row SELECT, byte for byte, for all three notification types', async () => {
const { user: recipient } = createUser(testDb);
const { user: sender } = createUser(testDb);
const { simpleId, booleanId, navigateId } = seedThreeTypes(recipient.id, sender.id);
for (const id of [simpleId, booleanId, navigateId]) {
const legacy = legacyFindById(id);
const converted = await repo.findById(id);
expect(converted).toEqual(legacy);
}
});
it('NOTREPO-002 — findByIdForRecipient matches the legacy recipient-scoped SELECT, and returns undefined for the wrong recipient', async () => {
const { user: recipient } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const { booleanId } = seedThreeTypes(recipient.id, null);
const legacy = testDb.prepare('SELECT * FROM notifications WHERE id = ? AND recipient_id = ?').get(booleanId, recipient.id);
expect(await repo.findByIdForRecipient(booleanId, recipient.id)).toEqual(legacy);
expect(await repo.findByIdForRecipient(booleanId, stranger.id)).toBeUndefined();
});
it('NOTREPO-003 — findWithSenderById matches the legacy LEFT JOIN users projection (sender_username/sender_avatar), including a NULL sender', async () => {
const { user: recipient } = createUser(testDb);
const { user: sender } = createUser(testDb, { username: 'Alice', email: 'alice@test.example.com' });
const { simpleId } = seedThreeTypes(recipient.id, sender.id);
const legacy = testDb.prepare(`
SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar
FROM notifications n LEFT JOIN users u ON n.sender_id = u.id
WHERE n.id = ?
`).get(simpleId);
expect(await repo.findWithSenderById(simpleId)).toEqual(legacy);
// A system notification (no sender) — the LEFT JOIN's NULL side.
const systemId = testDb.prepare(`
INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, title_params, text_key, text_params)
VALUES ('simple', 'admin', 0, NULL, ?, 'notif.version_available.title', '{}', 'notif.version_available.text', '{}')
`).run(recipient.id).lastInsertRowid as number;
const legacySystem = testDb.prepare(`
SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar
FROM notifications n LEFT JOIN users u ON n.sender_id = u.id
WHERE n.id = ?
`).get(systemId);
expect(await repo.findWithSenderById(systemId)).toEqual(legacySystem);
});
it('NOTREPO-004 — listForRecipient matches the legacy joined page read, for both unreadOnly variants', async () => {
const { user: recipient } = createUser(testDb);
const { user: sender } = createUser(testDb);
seedThreeTypes(recipient.id, sender.id);
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE recipient_id = ? LIMIT 1').run(recipient.id);
// SQLite compiled without LIMIT-on-UPDATE support in better-sqlite3 by default — fall back to an id-scoped update.
const firstId = (testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ? ORDER BY id LIMIT 1').get(recipient.id) as { id: number }).id;
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(firstId);
for (const unreadOnly of [false, true]) {
const whereAliased = unreadOnly ? 'WHERE n.recipient_id = ? AND n.is_read = 0' : 'WHERE n.recipient_id = ?';
const legacy = testDb.prepare(`
SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar
FROM notifications n LEFT JOIN users u ON n.sender_id = u.id
${whereAliased}
ORDER BY n.created_at DESC
LIMIT ? OFFSET ?
`).all(recipient.id, 20, 0);
const converted = await repo.listForRecipient(recipient.id, 20, 0, unreadOnly);
expect(converted).toEqual(legacy);
}
});
});
describe('NotificationsRepository — recipient resolution (NT1-NT4)', () => {
it('NOTREPO-005 — getTripOwnerId matches `SELECT user_id FROM trips WHERE id = ?`', async () => {
const legacy = (testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number }).user_id;
expect(await repo.getTripOwnerId(trip.id)).toBe(legacy);
expect(await repo.getTripOwnerId(999999)).toBeNull();
});
it('NOTREPO-006 — listNonGuestTripMemberIds (NT2, the guest-exclusion chokepoint, #1362): a guest member never appears, a real member always does', async () => {
const { user: member } = createUser(testDb);
addTripMember(testDb, trip.id, member.id);
const guestId = (testDb.prepare(
"INSERT INTO users (username, email, password_hash, role, is_guest) VALUES ('Guest', 'guest-repo@guests.invalid', '', 'user', 1)",
).run()).lastInsertRowid as number;
addTripMember(testDb, trip.id, guestId);
const legacy = (testDb.prepare(
'SELECT m.user_id FROM trip_members m JOIN users u ON u.id = m.user_id WHERE m.trip_id = ? AND COALESCE(u.is_guest, 0) = 0',
).all(trip.id) as { user_id: number }[]).map((r) => r.user_id);
const converted = await repo.listNonGuestTripMemberIds(trip.id);
expect(converted.sort()).toEqual(legacy.sort());
expect(converted).toContain(member.id);
expect(converted).not.toContain(guestId);
});
it('NOTREPO-007 — findGuestFlag matches `SELECT is_guest FROM users WHERE id = ?`, and is undefined for a nonexistent user', async () => {
const { user } = createUser(testDb);
expect(await repo.findGuestFlag(user.id)).toEqual({ is_guest: 0 });
expect(await repo.findGuestFlag(999999)).toBeUndefined();
});
it('NOTREPO-008 — listNonGuestUserIdsByRole matches `SELECT id FROM users WHERE role = ? AND COALESCE(is_guest, 0) = 0`', async () => {
const { user: admin1 } = createUser(testDb, { role: 'admin' });
const { user: admin2 } = createUser(testDb, { role: 'admin' });
createUser(testDb); // a regular user — must not appear
const guestAdminId = (testDb.prepare(
"INSERT INTO users (username, email, password_hash, role, is_guest) VALUES ('GuestAdmin', 'guest-admin@guests.invalid', '', 'admin', 1)",
).run()).lastInsertRowid as number;
const legacy = (testDb.prepare(
'SELECT id FROM users WHERE role = ? AND COALESCE(is_guest, 0) = 0',
).all('admin') as { id: number }[]).map((r) => r.id);
const converted = await repo.listNonGuestUserIdsByRole('admin');
expect(converted.sort()).toEqual(legacy.sort());
expect(converted).toContain(admin1.id);
expect(converted).toContain(admin2.id);
expect(converted).not.toContain(guestAdminId);
});
it('NOTREPO-009 — findUserBasic matches `SELECT username, avatar FROM users WHERE id = ?`', async () => {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('avatar.png', user.id);
const legacy = testDb.prepare('SELECT username, avatar FROM users WHERE id = ?').get(user.id);
expect(await repo.findUserBasic(user.id)).toEqual(legacy);
expect(await repo.findUserBasic(999999)).toBeUndefined();
});
});
describe('NotificationsRepository — writes', () => {
it('NOTREPO-010 — insertNotification writes all 15 columns and returns the new id', async () => {
const { user: recipient } = createUser(testDb);
const { user: sender } = createUser(testDb);
const id = await repo.insertNotification({
type: 'boolean', scope: 'trip', target: trip.id, sender_id: sender.id, recipient_id: recipient.id,
title_key: 'notif.trip_invite.title', title_params: '{"trip":"Rome"}', text_key: 'notif.trip_invite.text', text_params: '{"actor":"A"}',
positive_text_key: 'notif.action.accept', negative_text_key: 'notif.action.decline',
positive_callback: '{"action":"noop","payload":{}}', negative_callback: '{"action":"noop","payload":{}}',
navigate_text_key: null, navigate_target: null,
});
const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as Record<string, unknown>;
expect(row.type).toBe('boolean');
expect(row.scope).toBe('trip');
expect(row.target).toBe(trip.id);
expect(row.sender_id).toBe(sender.id);
expect(row.recipient_id).toBe(recipient.id);
expect(row.positive_callback).toBe('{"action":"noop","payload":{}}');
expect(row.is_read).toBe(0);
expect(row.response).toBeNull();
});
it('NOTREPO-011 — setRead/markAllRead/deleteForRecipient/deleteAllForRecipient are recipient-scoped and return the affected-row count', async () => {
const { user: recipient } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const { simpleId, booleanId } = seedThreeTypes(recipient.id, null);
expect(await repo.setRead(simpleId, stranger.id, 1)).toBe(0); // wrong recipient — no-op
expect(await repo.setRead(simpleId, recipient.id, 1)).toBe(1);
expect((testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(simpleId) as { is_read: number }).is_read).toBe(1);
expect(await repo.setRead(simpleId, recipient.id, 0)).toBe(1);
expect(await repo.markAllRead(recipient.id)).toBe(3); // simple + boolean + navigate, all currently unread
expect(await repo.countUnreadForRecipient(recipient.id)).toBe(0);
expect(await repo.deleteForRecipient(booleanId, stranger.id)).toBe(0);
expect(await repo.deleteForRecipient(booleanId, recipient.id)).toBe(1);
expect(await repo.deleteAllForRecipient(recipient.id)).toBe(2); // simple + navigate remain
expect(await repo.countForRecipient(recipient.id, false)).toBe(0);
});
it('NOTREPO-012 — claimResponse (NT20) only succeeds once: the atomic double-submit claim', async () => {
const { user: recipient } = createUser(testDb);
const { booleanId } = seedThreeTypes(recipient.id, null);
expect(await repo.claimResponse(booleanId, recipient.id, 'positive')).toBe(1);
// A second claim on the same, now-claimed notification affects 0 rows —
// the `response IS NULL` guard is the load-bearing part of this statement.
expect(await repo.claimResponse(booleanId, recipient.id, 'negative')).toBe(0);
expect((testDb.prepare('SELECT response FROM notifications WHERE id = ?').get(booleanId) as { response: string }).response).toBe('positive');
});
it('NOTREPO-013 — releaseResponse (NT21) restores the claim so a retry can succeed', async () => {
const { user: recipient } = createUser(testDb);
const { booleanId } = seedThreeTypes(recipient.id, null);
await repo.claimResponse(booleanId, recipient.id, 'positive');
await repo.releaseResponse(booleanId, recipient.id, 0);
const row = testDb.prepare('SELECT response, is_read FROM notifications WHERE id = ?').get(booleanId) as { response: string | null; is_read: number };
expect(row.response).toBeNull();
expect(row.is_read).toBe(0);
expect(await repo.claimResponse(booleanId, recipient.id, 'negative')).toBe(1);
});
});
@@ -68,7 +68,8 @@ 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 { createTestUnitOfWork, createTestAppSettingsRepo } from '../../helpers/test-uow';
import { createTestNotificationChannelPreferencesRepo } from '../../helpers/notifications-repos';
import {
setPluginChannelSource,
@@ -91,7 +92,12 @@ void WebhookService; void NtfyService;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
prefsSvc = new NotificationPreferencesService(prefsDbs, new MailerService(prefsDbs), await createTestUnitOfWork(testDb));
prefsSvc = new NotificationPreferencesService(
new MailerService(prefsDbs),
await createTestUnitOfWork(testDb),
await createTestAppSettingsRepo(testDb),
await createTestNotificationChannelPreferencesRepo(testDb),
);
notifications = await makeNotificationsService(new DatabaseService(testDb));
});
beforeEach(() => { resetTestDb(testDb); setPluginChannelSource(null); });
@@ -43,7 +43,8 @@ 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';
import { createTestUnitOfWork, createTestAppSettingsRepo } from '../../helpers/test-uow';
import { createTestNotificationChannelPreferencesRepo } from '../../helpers/notifications-repos';
const dbs = new DatabaseService(testDb);
const mailer = new MailerService(dbs);
@@ -69,7 +70,12 @@ const isWebhookConfigured = (...a: Parameters<Svc['isWebhookConfigured']>) => sv
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
svc = new NotificationPreferencesService(dbs, mailer, await createTestUnitOfWork(testDb));
svc = new NotificationPreferencesService(
mailer,
await createTestUnitOfWork(testDb),
await createTestAppSettingsRepo(testDb),
await createTestNotificationChannelPreferencesRepo(testDb),
);
});
beforeEach(() => {
@@ -312,6 +312,24 @@ describe('send() — recipient resolution', () => {
expect(recipients).not.toContain(guestId);
});
it('NSVC-007c — guest-exclusion (#1362), the direct call: resolveRecipients itself never returns a guest for trip or user scope (Plan 3f Task 3 — the NT2 chokepoint, R8)', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', owner.id)).lastInsertRowid as number;
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member.id);
const guestId = (testDb.prepare("INSERT INTO users (username, email, password_hash, role, is_guest) VALUES ('Guest', 'guest-y@guests.invalid', '', 'user', 1)").run()).lastInsertRowid as number;
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, guestId);
const tripRecipients = await notifications.resolveRecipients('trip', tripId);
expect(tripRecipients).toContain(owner.id);
expect(tripRecipients).toContain(member.id);
expect(tripRecipients).not.toContain(guestId);
const userRecipients = await notifications.resolveRecipients('user', guestId);
expect(userRecipients).toEqual([]);
});
it('NSVC-008 — user scope sends to exactly one user', async () => {
const { user: target } = createUser(testDb);
const { user: other } = createUser(testDb);