feat: notifications in the app and, optionally, by e-mail

Derived from events and kept per person: whoever a ticket is assigned to,
whoever reports or works on it when it moves, fails QA or gets a comment,
and whoever joins a project. Never about what you did yourself, once per
event. They arrive live through a subscription to a bell in the header,
and are copied by e-mail to whoever turns it on when SMTP_URL is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 09:19:59 +02:00
parent 0b65c1c912
commit efa06a0723
60 changed files with 3406 additions and 28 deletions

View File

@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { NotificationsRepository } from '@task-stream/api/tracker/application';
import {
NotificationReadModel,
NotificationSettingsReadModel,
} from '@task-stream/api/tracker/domain';
import { Model } from 'mongoose';
import {
fromDoc,
NotificationDoc,
NotificationSettingsDoc,
ReadModels,
} from './read-model.schemas';
@Injectable()
export class MongoNotificationsRepository extends NotificationsRepository {
constructor(
@InjectModel(ReadModels.Notification)
private readonly notifications: Model<NotificationDoc>,
@InjectModel(ReadModels.NotificationSettings)
private readonly settingsDocs: Model<NotificationSettingsDoc>,
) {
super();
}
async insert({ id, ...rest }: NotificationReadModel): Promise<boolean> {
const result = await this.notifications.updateOne(
{ _id: id },
{ $setOnInsert: rest },
{ upsert: true },
);
return result.upsertedCount > 0;
}
async findForUser(
userId: string,
options: { unreadOnly: boolean; limit: number },
): Promise<NotificationReadModel[]> {
const docs = await this.notifications
.find({ userId, ...(options.unreadOnly ? { readAt: null } : {}) })
.sort({ createdAt: -1 })
.limit(options.limit)
.lean<NotificationDoc[]>();
return docs.map((doc) => fromDoc(doc) as NotificationReadModel);
}
countUnread(userId: string): Promise<number> {
return this.notifications.countDocuments({ userId, readAt: null });
}
async markRead(userId: string, ids: string[] | null): Promise<void> {
await this.notifications.updateMany(
{ userId, readAt: null, ...(ids ? { _id: { $in: ids } } : {}) },
{ $set: { readAt: new Date().toISOString() } },
);
}
async settings(userId: string): Promise<NotificationSettingsReadModel> {
const doc = await this.settingsDocs
.findById(userId)
.lean<NotificationSettingsDoc>();
return { userId, email: doc?.email ?? false };
}
async saveSettings(settings: NotificationSettingsReadModel): Promise<void> {
await this.settingsDocs.updateOne(
{ _id: settings.userId },
{ $set: { email: settings.email } },
{ upsert: true },
);
}
}