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,49 @@
import { inject, Injectable } from '@angular/core';
import {
Notification,
NotificationSettings,
NotificationsGateway,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
MarkNotificationsReadDocument,
MyNotificationsDocument,
NotificationSettingsDocument,
UpdateNotificationSettingsDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@Injectable()
export class GraphqlNotificationsGateway extends NotificationsGateway {
private readonly gql = inject(GraphqlOperations);
mine(
unreadOnly: boolean,
limit: number,
): Observable<{ items: Notification[]; unread: number }> {
return this.gql.query(MyNotificationsDocument, { unreadOnly, limit }).pipe(
map(({ myNotifications }) => ({
unread: myNotifications.unread,
items: myNotifications.items as Notification[],
})),
);
}
markRead(ids?: string[]): Observable<void> {
return this.gql
.mutate(MarkNotificationsReadDocument, { ids: ids ?? null })
.pipe(map(() => undefined));
}
settings(): Observable<NotificationSettings> {
return this.gql
.query(NotificationSettingsDocument)
.pipe(map((d) => ({ ...d.notificationSettings })));
}
updateSettings(email: boolean): Observable<NotificationSettings> {
return this.gql
.mutate(UpdateNotificationSettingsDocument, { email })
.pipe(map((d) => ({ ...d.updateNotificationSettings })));
}
}

View File

@@ -4,6 +4,7 @@ import {
Activity,
CommentChanged,
IssueChanged,
Notification,
ProjectChanged,
Release,
TrackerRealtimeGateway,
@@ -14,6 +15,7 @@ import {
CommentChangedDocument,
IssueChangedDocument,
MyProjectsChangedDocument,
NotificationAddedDocument,
ProjectChangedDocument,
ReleaseChangedDocument,
} from '../graphql/generated';
@@ -98,6 +100,14 @@ export class GraphqlRealtimeGateway extends TrackerRealtimeGateway {
.pipe(map((d) => toProjectChanged(d.myProjectsChanged))),
);
}
notifications(): Observable<Notification> {
return this.signedIn(() =>
this.gql
.subscribe(NotificationAddedDocument)
.pipe(map((d) => d.notificationAdded as Notification)),
);
}
}
const toProjectChanged = (change: {