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

@@ -5,3 +5,4 @@ export * from './lib/facades/board.facade';
export * from './lib/facades/issue.facade';
export * from './lib/facades/home.facade';
export * from './lib/facades/explore.facade';
export * from './lib/facades/inbox.facade';

View File

@@ -0,0 +1,37 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import {
Notification,
NotificationSettings,
} from '@task-stream/web/tracker/domain';
/* Notifications are "the inbox" here, so as not to clash with `Notifications` (the toasts port). */
export const inboxPageEvents = eventGroup({
source: 'Inbox',
events: {
/** Signed in: load, and follow new ones. */
opened: type<void>(),
/** Signed out: forget everything. */
closed: type<void>(),
markReadRequested: type<{ ids?: string[] }>(),
emailToggled: type<boolean>(),
},
});
export const inboxApiEvents = eventGroup({
source: 'Inbox API',
events: {
loaded: type<{ items: Notification[]; unread: number }>(),
settingsLoaded: type<NotificationSettings>(),
markedRead: type<{ ids?: string[]; at: string }>(),
failed: type<string>(),
},
});
export const inboxRealtimeEvents = eventGroup({
source: 'Inbox Realtime',
events: {
arrived: type<Notification>(),
},
});

View File

@@ -7,6 +7,7 @@ import {
AttachmentsGateway,
CommentsGateway,
IssuesGateway,
NotificationsGateway,
Project,
ProjectRole,
ProjectsGateway,
@@ -21,6 +22,7 @@ import { projectPageEvents } from '../events/project.events';
import { Notifications } from '../notifications';
import { BoardFacade, provideBoardFacade } from './board.facade';
import { HomeFacade } from './home.facade';
import { InboxFacade } from './inbox.facade';
import { IssueFacade, provideIssueFacade } from './issue.facade';
import { ProjectFacade, provideProjectFacade } from './project.facade';
@@ -307,3 +309,84 @@ describe('HomeFacade', () => {
expect(home.releases().map((r) => r.name)).toEqual(['v1.4.0']);
});
});
describe('InboxFacade', () => {
const note = (id: string, readAt: string | null = null) =>
({
id,
kind: 'ASSIGNED',
projectId: 'p1',
projectKey: 'WEB',
issueId: 'i1',
issueKey: 'WEB-1',
actor: { id: 'ana', name: 'Ana' },
summary: `Aviso ${id}`,
readAt,
createdAt: '2026-10-08T10:00:00.000Z',
}) as never;
function inbox(signedIn = true) {
const arrivals = new Subject<never>();
const gateway = {
mine: vi.fn(() =>
of({ items: [note('n1'), note('n0', 'x')], unread: 1 }),
),
settings: vi.fn(() => of({ email: false, emailAvailable: true })),
markRead: vi.fn(() => of(undefined)),
updateSettings: vi.fn((email: boolean) =>
of({ email, emailAvailable: true }),
),
};
TestBed.configureTestingModule({
providers: [
{ provide: NotificationsGateway, useValue: gateway },
{
provide: TrackerRealtimeGateway,
useValue: { notifications: () => arrivals },
},
{
provide: Notifications,
useValue: { success: vi.fn(), error: vi.fn() },
},
{
provide: AuthFacade,
useValue: { isAuthenticated: signal(signedIn) },
},
],
});
const facade = TestBed.inject(InboxFacade);
TestBed.tick();
return { facade, gateway, arrivals };
}
it('loads once signed in and counts what arrives live', () => {
const { facade, arrivals } = inbox();
expect(facade.unread()).toBe(1);
arrivals.next(note('n2'));
arrivals.next(note('n2'));
expect(facade.unread()).toBe(2);
expect(facade.items().map((n) => n.id)).toEqual(['n2', 'n1', 'n0']);
});
it('marks one or all as read', () => {
const { facade, gateway, arrivals } = inbox();
arrivals.next(note('n2'));
facade.markRead(['n2']);
expect(facade.unread()).toBe(1);
facade.markRead();
expect(gateway.markRead).toHaveBeenLastCalledWith(undefined);
expect(facade.unread()).toBe(0);
expect(facade.items().every((n) => n.readAt)).toBe(true);
});
it('asks nothing without a session', () => {
const { gateway, facade } = inbox(false);
expect(gateway.mine).not.toHaveBeenCalled();
expect(facade.items()).toEqual([]);
});
});

View File

@@ -0,0 +1,37 @@
import { effect, inject, Injectable } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { AuthFacade } from '@task-stream/web/identity/application';
import { inboxPageEvents } from '../events/inbox.events';
import { InboxStore } from '../stores/inbox.store';
/**
* The signed-in person's notifications for presenters: loaded and followed
* live while there is a session, forgotten when it ends.
*/
@Injectable({ providedIn: 'root' })
export class InboxFacade {
private readonly store = inject(InboxStore);
private readonly dispatch = injectDispatch(inboxPageEvents);
private readonly auth = inject(AuthFacade);
readonly items = this.store.items;
readonly unread = this.store.unread;
readonly hasUnread = this.store.hasUnread;
readonly settings = this.store.settings;
constructor() {
effect(() => {
if (this.auth.isAuthenticated()) this.dispatch.opened();
else this.dispatch.closed();
});
}
/** Without ids: all of them. */
markRead(ids?: string[]): void {
this.dispatch.markReadRequested({ ids });
}
setEmail(enabled: boolean): void {
this.dispatch.emailToggled(enabled);
}
}

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))),
),
};
},
),
);