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

@@ -17,6 +17,7 @@ import { MatIconRegistry } from '@angular/material/icon';
export const APP_ICONS: Record<string, string> = {
'arrow-forward':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h15M13 6l6 6-6 6"/></svg>',
bell: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15L6 16ZM10 20.5a2 2 0 0 0 4 0"/></svg>',
briefcase:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M9 7V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2M3 12h18"/></svg>',
buildings:

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

View File

@@ -38,3 +38,15 @@ export enum ReleaseStatus {
RELEASED = 'RELEASED',
ARCHIVED = 'ARCHIVED',
}
/** Why someone is told. */
export enum NotificationKind {
ASSIGNED = 'ASSIGNED',
STATUS_CHANGED = 'STATUS_CHANGED',
QA_FAILED = 'QA_FAILED',
COMMENTED = 'COMMENTED',
MENTIONED = 'MENTIONED',
WATCHED = 'WATCHED',
ADDED_TO_PROJECT = 'ADDED_TO_PROJECT',
REMINDER = 'REMINDER',
}

View File

@@ -1,4 +1,5 @@
import {
NotificationKind,
IssueLinkType,
IssuePriority,
ProjectRole,
@@ -187,3 +188,25 @@ export interface CommandResult {
key?: string | null;
version: number;
}
/** Something the signed-in person should know. */
export interface Notification {
id: string;
kind: NotificationKind;
projectId: string;
projectKey: string;
issueId: string | null;
issueKey: string | null;
actor: UserRef;
summary: string;
/** Null: unread. */
readAt: string | null;
createdAt: string;
}
export interface NotificationSettings {
/** Copies by e-mail. */
email: boolean;
/** Whether this installation can send e-mail at all. */
emailAvailable: boolean;
}

View File

@@ -8,6 +8,8 @@ import {
Issue,
IssuePage,
IssueSummary,
Notification,
NotificationSettings,
Project,
ProjectAnalytics,
ProjectStats,
@@ -130,6 +132,18 @@ export abstract class UsersGateway {
abstract search(term: string): Observable<UserDirectoryEntry[]>;
}
/** The signed-in person's notifications. */
export abstract class NotificationsGateway {
abstract mine(
unreadOnly: boolean,
limit: number,
): Observable<{ items: Notification[]; unread: number }>;
/** Without ids: all of them. */
abstract markRead(ids?: string[]): Observable<void>;
abstract settings(): Observable<NotificationSettings>;
abstract updateSettings(email: boolean): Observable<NotificationSettings>;
}
/** Realtime (GraphQL subscriptions over WebSocket). */
export interface IssueChanged {
action: string;
@@ -158,4 +172,5 @@ export abstract class TrackerRealtimeGateway {
abstract releaseChanges(projectId: string): Observable<Release>;
abstract projectChanges(projectId: string): Observable<ProjectChanged>;
abstract myProjectsChanges(): Observable<ProjectChanged>;
abstract notifications(): Observable<Notification>;
}

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: {

View File

@@ -96,6 +96,17 @@ export type LinkIssuesInput = {
type: IssueLinkType;
};
/** Why someone is told */
export type NotificationKind =
| 'ADDED_TO_PROJECT'
| 'ASSIGNED'
| 'COMMENTED'
| 'MENTIONED'
| 'QA_FAILED'
| 'REMINDER'
| 'STATUS_CHANGED'
| 'WATCHED';
export type ProjectMemberInput = {
projectId: string;
role?: ProjectRole;
@@ -524,6 +535,83 @@ export type MyIssuesQuery = {
};
};
export type NotificationFieldsFragment = {
id: string;
kind: NotificationKind;
projectId: string;
projectKey: string;
issueId: string | null;
issueKey: string | null;
summary: string;
readAt: string | null;
createdAt: string;
actor: { id: string; name: string };
};
export type MyNotificationsQueryVariables = Exact<{
unreadOnly: boolean;
limit: number;
}>;
export type MyNotificationsQuery = {
myNotifications: {
unread: number;
items: Array<{
id: string;
kind: NotificationKind;
projectId: string;
projectKey: string;
issueId: string | null;
issueKey: string | null;
summary: string;
readAt: string | null;
createdAt: string;
actor: { id: string; name: string };
}>;
};
};
export type NotificationSettingsQueryVariables = Exact<{
[key: string]: never;
}>;
export type NotificationSettingsQuery = {
notificationSettings: { email: boolean; emailAvailable: boolean };
};
export type MarkNotificationsReadMutationVariables = Exact<{
ids?: Array<string> | string | null | undefined;
}>;
export type MarkNotificationsReadMutation = { markNotificationsRead: boolean };
export type UpdateNotificationSettingsMutationVariables = Exact<{
email: boolean;
}>;
export type UpdateNotificationSettingsMutation = {
updateNotificationSettings: { email: boolean; emailAvailable: boolean };
};
export type NotificationAddedSubscriptionVariables = Exact<{
[key: string]: never;
}>;
export type NotificationAddedSubscription = {
notificationAdded: {
id: string;
kind: NotificationKind;
projectId: string;
projectKey: string;
issueId: string | null;
issueKey: string | null;
summary: string;
readAt: string | null;
createdAt: string;
actor: { id: string; name: string };
};
};
export type MyProjectsQueryVariables = Exact<{ [key: string]: never }>;
export type MyProjectsQuery = {
@@ -1559,6 +1647,61 @@ export const CommandResultFieldsFragmentDoc = {
},
],
} as unknown as DocumentNode<CommandResultFieldsFragment, unknown>;
export const NotificationFieldsFragmentDoc = {
kind: 'Document',
definitions: [
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'NotificationFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Notification' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectKey' } },
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },
{ kind: 'Field', name: { kind: 'Name', value: 'readAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'actor' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
],
} as unknown as DocumentNode<NotificationFieldsFragment, unknown>;
export const BoardDocument = {
kind: 'Document',
definitions: [
@@ -3155,6 +3298,358 @@ export const MyIssuesDocument = {
},
],
} as unknown as DocumentNode<MyIssuesQuery, MyIssuesQueryVariables>;
export const MyNotificationsDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'MyNotifications' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'unreadOnly' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Boolean' },
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'limit' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'myNotifications' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'unreadOnly' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'unreadOnly' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'limit' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'limit' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'unread' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'items' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'NotificationFields' },
},
],
},
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'NotificationFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Notification' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectKey' } },
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },
{ kind: 'Field', name: { kind: 'Name', value: 'readAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'actor' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
MyNotificationsQuery,
MyNotificationsQueryVariables
>;
export const NotificationSettingsDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'NotificationSettings' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'notificationSettings' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'email' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'emailAvailable' },
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
NotificationSettingsQuery,
NotificationSettingsQueryVariables
>;
export const MarkNotificationsReadDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'MarkNotificationsRead' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'ids' } },
type: {
kind: 'ListType',
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'markNotificationsRead' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'ids' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'ids' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<
MarkNotificationsReadMutation,
MarkNotificationsReadMutationVariables
>;
export const UpdateNotificationSettingsDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'UpdateNotificationSettings' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'email' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Boolean' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'updateNotificationSettings' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'email' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'email' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'email' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'emailAvailable' },
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
UpdateNotificationSettingsMutation,
UpdateNotificationSettingsMutationVariables
>;
export const NotificationAddedDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'subscription',
name: { kind: 'Name', value: 'NotificationAdded' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'notificationAdded' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'NotificationFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'NotificationFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Notification' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectKey' } },
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },
{ kind: 'Field', name: { kind: 'Name', value: 'readAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'actor' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
NotificationAddedSubscription,
NotificationAddedSubscriptionVariables
>;
export const MyProjectsDocument = {
kind: 'Document',
definitions: [

View File

@@ -0,0 +1,47 @@
fragment NotificationFields on Notification {
id
kind
projectId
projectKey
issueId
issueKey
summary
readAt
createdAt
actor {
...UserRefFields
}
}
query MyNotifications($unreadOnly: Boolean!, $limit: Int!) {
myNotifications(unreadOnly: $unreadOnly, limit: $limit) {
unread
items {
...NotificationFields
}
}
}
query NotificationSettings {
notificationSettings {
email
emailAvailable
}
}
mutation MarkNotificationsRead($ids: [ID!]) {
markNotificationsRead(ids: $ids)
}
mutation UpdateNotificationSettings($email: Boolean!) {
updateNotificationSettings(email: $email) {
email
emailAvailable
}
}
subscription NotificationAdded {
notificationAdded {
...NotificationFields
}
}

View File

@@ -7,6 +7,7 @@ import {
AttachmentsGateway,
CommentsGateway,
IssuesGateway,
NotificationsGateway,
ProjectsGateway,
ReleasesGateway,
TrackerRealtimeGateway,
@@ -19,6 +20,7 @@ import {
GraphqlUsersGateway,
} from './adapters/graphql-collaboration.gateways';
import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway';
import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.gateway';
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
@@ -33,6 +35,7 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
{ provide: ActivityGateway, useClass: GraphqlActivityGateway },
{ provide: AttachmentsGateway, useClass: HttpAttachmentsGateway },
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
]);
}

View File

@@ -1 +1,2 @@
export * from './lib/tracker.routes';
export * from './lib/ui/notifications-bell';

View File

@@ -0,0 +1,223 @@
import {
ChangeDetectionStrategy,
Component,
inject,
TemplateRef,
viewChild,
} from '@angular/core';
import { Router } from '@angular/router';
import {
PiActionButtonComponent,
PiBadgeComponent,
PiIconButtonComponent,
PiIconComponent,
PiSlideToggleComponent,
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import { InboxFacade } from '@task-stream/web/tracker/application';
import {
Notification,
NotificationKind,
} from '@task-stream/web/tracker/domain';
import { timeAgo } from './copy';
const ICON: Record<NotificationKind, string> = {
[NotificationKind.ASSIGNED]: 'user-profile',
[NotificationKind.STATUS_CHANGED]: 'arrow-forward',
[NotificationKind.QA_FAILED]: 'exclamation',
[NotificationKind.COMMENTED]: 'chat',
[NotificationKind.MENTIONED]: 'chat',
[NotificationKind.WATCHED]: 'focus',
[NotificationKind.ADDED_TO_PROJECT]: 'briefcase',
[NotificationKind.REMINDER]: 'calendar',
};
/**
* The bell in the header: how many unread, and the list in a side panel.
* Opening one goes to its ticket (or project) and marks it read.
*/
@Component({
selector: 'ts-notifications-bell',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiActionButtonComponent,
PiBadgeComponent,
PiIconButtonComponent,
PiIconComponent,
PiSlideToggleComponent,
],
template: `
<pi-badge
[value]="inbox.unread()"
[max]="99"
color="warn"
size="small"
[isHidden]="!inbox.hasUnread()"
>
<pi-icon-button
icon="bell"
color="grey"
appearance="ghost"
[attr.aria-label]="
inbox.unread()
? 'Notificaciones: ' + inbox.unread() + ' sin leer'
: 'Notificaciones'
"
(execute)="open()"
/>
</pi-badge>
<ng-template #panel>
<div class="ts-inbox">
<div class="ts-inbox__actions">
<pi-action-button
size="small"
color="grey"
appearance="outline"
text="Marcar todo como leído"
[disabled]="!inbox.hasUnread()"
(execute)="inbox.markRead()"
/>
@if (inbox.settings()?.emailAvailable) {
<pi-slide-toggle
label="Copia por correo"
[checked]="inbox.settings()?.email ?? false"
(checkedChange)="inbox.setEmail($event)"
/>
}
</div>
@if (inbox.items().length) {
<ul class="ts-inbox__list">
@for (item of inbox.items(); track item.id) {
<li>
<button
type="button"
class="ts-inbox__item"
[class.is-unread]="!item.readAt"
(click)="go(item)"
>
<span class="ts-inbox__icon" aria-hidden="true">
<pi-icon [icon]="icon(item)" size="small" />
</span>
<span class="ts-inbox__text">
<span class="ts-inbox__summary">{{ item.summary }}</span>
<span class="ts-inbox__meta"
>{{ item.projectKey }} · {{ ago(item.createdAt) }}</span
>
</span>
@if (!item.readAt) {
<span class="ts-inbox__dot" aria-label="Sin leer"></span>
}
</button>
</li>
}
</ul>
} @else {
<p class="ts-inbox__empty">
Nada nuevo. Te avisaremos cuando te asignen algo, comenten en tus
tickets o los muevan.
</p>
}
</div>
</ng-template>
`,
styles: `
:host {
display: inline-flex;
align-items: center;
}
.ts-inbox {
display: grid;
gap: 1rem;
}
.ts-inbox__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.ts-inbox__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.25rem;
}
.ts-inbox__item {
width: 100%;
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.625rem 0.5rem;
border: 0;
border-radius: 0.5rem;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.ts-inbox__item:hover,
.ts-inbox__item:focus-visible {
background: rgba(127, 127, 127, 0.12);
}
.ts-inbox__item.is-unread .ts-inbox__summary {
font-weight: 600;
}
.ts-inbox__icon {
flex: none;
width: 2rem;
height: 2rem;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(127, 127, 127, 0.14);
}
.ts-inbox__text {
display: grid;
gap: 0.125rem;
flex: 1;
min-width: 0;
}
.ts-inbox__meta {
font-size: 0.8125rem;
opacity: 0.7;
}
.ts-inbox__dot {
flex: none;
width: 0.5rem;
height: 0.5rem;
margin-top: 0.5rem;
border-radius: 50%;
background: #a347e1;
}
.ts-inbox__empty {
opacity: 0.7;
}
`,
})
export class NotificationsBell {
protected readonly inbox = inject(InboxFacade);
private readonly drawer = inject(DrawerService);
private readonly router = inject(Router);
private readonly panel = viewChild.required<TemplateRef<unknown>>('panel');
protected readonly ago = timeAgo;
protected icon = (item: Notification) => ICON[item.kind] ?? 'info';
protected open(): void {
this.drawer.open('Notificaciones', this.panel(), '28rem');
}
protected go(item: Notification): void {
if (!item.readAt) this.inbox.markRead([item.id]);
this.drawer.closeAll();
const project = encodeURIComponent(item.projectKey);
void this.router.navigateByUrl(
item.issueKey
? `/p/${project}/issues/${encodeURIComponent(item.issueKey)}`
: `/p/${project}`,
);
}
}