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:
37
libs/web/tracker/application/src/lib/events/inbox.events.ts
Normal file
37
libs/web/tracker/application/src/lib/events/inbox.events.ts
Normal 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>(),
|
||||
},
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
37
libs/web/tracker/application/src/lib/facades/inbox.facade.ts
Normal file
37
libs/web/tracker/application/src/lib/facades/inbox.facade.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
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