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