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,143 @@
import { computed, inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { signalStore, withComputed, withState } from '@ngrx/signals';
import {
Events,
on,
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import {
Notification,
NotificationSettings,
NotificationsGateway,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import {
catchError,
EMPTY,
map,
merge,
mergeMap,
switchMap,
takeUntil,
tap,
} from 'rxjs';
import {
inboxApiEvents,
inboxPageEvents,
inboxRealtimeEvents,
} from '../events/inbox.events';
import { describeError, Notifications } from '../notifications';
interface InboxState {
items: Notification[];
unread: number;
settings: NotificationSettings | null;
}
const LIMIT = 30;
/** The signed-in person's notifications, live. One for the whole app. */
export const InboxStore = signalStore(
{ providedIn: 'root' },
withState<InboxState>({ items: [], unread: 0, settings: null }),
withComputed(({ unread }) => ({
hasUnread: computed(() => unread() > 0),
})),
withReducer(
on(inboxPageEvents.closed, () => ({
items: [],
unread: 0,
settings: null,
})),
on(inboxApiEvents.loaded, ({ payload }) => payload),
on(inboxApiEvents.settingsLoaded, ({ payload }) => ({ settings: payload })),
on(inboxRealtimeEvents.arrived, ({ payload }, state) =>
state.items.some((n) => n.id === payload.id)
? {}
: {
items: [payload, ...state.items].slice(0, LIMIT),
unread: state.unread + 1,
},
),
on(inboxApiEvents.markedRead, ({ payload }, state) => {
const ids = payload.ids ? new Set(payload.ids) : null;
const items = state.items.map((n) =>
!n.readAt && (!ids || ids.has(n.id)) ? { ...n, readAt: payload.at } : n,
);
const marked = state.items.filter(
(n) => !n.readAt && (!ids || ids.has(n.id)),
).length;
return {
items,
unread: ids ? Math.max(0, state.unread - marked) : 0,
};
}),
),
withEventHandlers(
(
_store,
events = inject(Events),
gateway = inject(NotificationsGateway),
realtime = inject(TrackerRealtimeGateway),
toasts = inject(Notifications),
) => {
const closed$ = events.on(inboxPageEvents.closed);
return {
load$: events.on(inboxPageEvents.opened).pipe(
switchMap(() =>
gateway.mine(false, LIMIT).pipe(
mapResponse({
next: (page) => inboxApiEvents.loaded(page),
error: (error) => inboxApiEvents.failed(describeError(error)),
}),
),
),
),
settings$: events.on(inboxPageEvents.opened).pipe(
switchMap(() =>
gateway.settings().pipe(
map((settings) => inboxApiEvents.settingsLoaded(settings)),
// Without settings the bell still works; only the e-mail switch hides.
catchError(() => EMPTY),
),
),
),
live$: events.on(inboxPageEvents.opened).pipe(
switchMap(() => realtime.notifications().pipe(takeUntil(closed$))),
map((notification) => inboxRealtimeEvents.arrived(notification)),
),
markRead$: events.on(inboxPageEvents.markReadRequested).pipe(
mergeMap(({ payload }) =>
gateway.markRead(payload.ids).pipe(
mapResponse({
next: () =>
inboxApiEvents.markedRead({
ids: payload.ids,
at: new Date().toISOString(),
}),
error: (error) => inboxApiEvents.failed(describeError(error)),
}),
),
),
),
email$: events.on(inboxPageEvents.emailToggled).pipe(
switchMap(({ payload }) =>
gateway.updateSettings(payload).pipe(
mapResponse({
next: (settings) => inboxApiEvents.settingsLoaded(settings),
error: (error) => inboxApiEvents.failed(describeError(error)),
}),
),
),
),
feedback$: merge(
events
.on(inboxApiEvents.failed)
.pipe(tap(({ payload }) => toasts.error(payload))),
),
};
},
),
);