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:
143
libs/web/tracker/application/src/lib/stores/inbox.store.ts
Normal file
143
libs/web/tracker/application/src/lib/stores/inbox.store.ts
Normal 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))),
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user