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

@@ -7,4 +7,6 @@ export * from './lib/comments.service';
export * from './lib/releases.service';
export * from './lib/changes.service';
export * from './lib/workflows.service';
export * from './lib/notifications.service';
export * from './lib/notifications';
export * from './lib/tracker-application.module';

View File

@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { NotificationReadModel } from '@task-stream/api/tracker/domain';
import {
MarkNotificationsReadCommand,
UpdateNotificationSettingsCommand,
} from './notifications/notifications.commands';
import {
GetMyNotificationSettingsQuery,
GetMyNotificationsQuery,
MyNotificationSettings,
MyNotifications,
} from './notifications/notifications.queries';
import { ChangeFeed } from './ports/change-feed';
/** The signed-in person's notifications: always their own, nobody else's. */
@Injectable()
export class NotificationsService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
private readonly changeFeed: ChangeFeed,
) {}
mine(
userId: string,
options: { unreadOnly?: boolean; limit?: number } = {},
): Promise<MyNotifications> {
return this.queryBus.execute(
new GetMyNotificationsQuery(
userId,
options.unreadOnly ?? false,
options.limit ?? 30,
),
);
}
/** `ids` omitted: everything. */
markRead(userId: string, ids?: string[] | null): Promise<void> {
return this.commandBus.execute(
new MarkNotificationsReadCommand(userId, ids?.length ? ids : null),
);
}
settings(userId: string): Promise<MyNotificationSettings> {
return this.queryBus.execute(new GetMyNotificationSettingsQuery(userId));
}
async updateSettings(
userId: string,
changes: { email: boolean },
): Promise<MyNotificationSettings> {
await this.commandBus.execute(
new UpdateNotificationSettingsCommand(userId, changes.email),
);
return this.settings(userId);
}
/** New notifications for this person, as they happen. */
watch(userId: string): AsyncIterableIterator<NotificationReadModel> {
return this.changeFeed.notifications(userId);
}
}

View File

@@ -0,0 +1,3 @@
export * from './notifier';
export * from './notifications.queries';
export * from './notifications.commands';

View File

@@ -0,0 +1,51 @@
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { NotificationsRepository } from '../ports/notifications.repository';
/*
* Per-person state, outside the event store: whether a notification was read
* and whether to copy them by e-mail concern nobody else and need no history.
*/
export class MarkNotificationsReadCommand extends Command<void> {
constructor(
public readonly userId: string,
/** Null: all of them. */
public readonly ids: string[] | null,
) {
super();
}
}
export class UpdateNotificationSettingsCommand extends Command<void> {
constructor(
public readonly userId: string,
public readonly email: boolean,
) {
super();
}
}
@CommandHandler(MarkNotificationsReadCommand)
export class MarkNotificationsReadCommandHandler
implements ICommandHandler<MarkNotificationsReadCommand>
{
constructor(private readonly notifications: NotificationsRepository) {}
async execute(command: MarkNotificationsReadCommand): Promise<void> {
await this.notifications.markRead(command.userId, command.ids);
}
}
@CommandHandler(UpdateNotificationSettingsCommand)
export class UpdateNotificationSettingsCommandHandler
implements ICommandHandler<UpdateNotificationSettingsCommand>
{
constructor(private readonly notifications: NotificationsRepository) {}
async execute(command: UpdateNotificationSettingsCommand): Promise<void> {
await this.notifications.saveSettings({
userId: command.userId,
email: command.email,
});
}
}

View File

@@ -0,0 +1,143 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
CommentAddedEvent,
IssueAssignedEvent,
IssueCreatedEvent,
IssueStatusChangedEvent,
NotificationKind,
ProjectMemberAddedEvent,
StatusCategory,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { UserDirectory } from '../ports/user-directory';
import { NotificationDraft, Notifier } from './notifier';
/* Read by people, like the activity feed: Spanish. */
const excerpt = (text: string, max = 120) => {
const flat = text.replace(/\s+/g, ' ').trim();
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
};
/**
* Who is told what, for the events that concern someone in particular:
* whoever a ticket is assigned to, whoever reported or works on it when it
* moves or gets a comment, whoever joins a project.
*/
@EventsHandler(
IssueCreatedEvent,
IssueAssignedEvent,
IssueStatusChangedEvent,
CommentAddedEvent,
ProjectMemberAddedEvent,
)
export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
constructor(
private readonly notifier: Notifier,
private readonly findIssues: FindIssuesRepository,
private readonly findProjects: FindProjectsRepository,
private readonly users: UserDirectory,
) {}
async handle(event: TrackerEvent): Promise<void> {
const project = await this.findProjects.findById(event.projectId);
if (!project) return;
const actor = (await this.users.refs([event.actorId])).of(
event.actorId,
).name;
if (event instanceof ProjectMemberAddedEvent) {
return this.notifier.deliver(
event,
{
projectId: project.id,
projectKey: project.key,
issueId: null,
issueKey: null,
},
[
{
userId: event.userId,
kind: NotificationKind.ADDED_TO_PROJECT,
summary: `${actor} te añadió al proyecto ${project.name} (${project.key})`,
},
],
);
}
const issueId =
event instanceof CommentAddedEvent
? event.comment.issueId
: event.aggregateId;
const issue =
event instanceof IssueCreatedEvent
? event.issue
: await this.findIssues.findById(issueId);
if (!issue) return;
const reporterId =
'reporterId' in issue ? issue.reporterId : issue.reporter.id;
const assigneeId =
'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null);
const ticket = `${issue.key}: ${issue.title}`;
const drafts: NotificationDraft[] = [];
if (event instanceof IssueCreatedEvent && assigneeId) {
drafts.push({
userId: assigneeId,
kind: NotificationKind.ASSIGNED,
summary: `${actor} te asignó ${ticket}`,
});
}
if (event instanceof IssueAssignedEvent && event.assigneeId) {
drafts.push({
userId: event.assigneeId,
kind: NotificationKind.ASSIGNED,
summary: `${actor} te asignó ${ticket}`,
});
}
if (event instanceof IssueStatusChangedEvent) {
const to = event.meta?.toName ?? event.to;
// Back from QA without passing: the one move whoever did the work must hear about.
const failedQa =
event.meta?.fromCategory === StatusCategory.QA &&
event.meta?.toCategory !== StatusCategory.QA &&
event.meta?.toCategory !== StatusCategory.DONE;
const reason = event.reason ? `: ${excerpt(event.reason)}` : '';
for (const userId of [assigneeId, reporterId]) {
if (!userId) continue;
drafts.push({
userId,
kind: failedQa
? NotificationKind.QA_FAILED
: NotificationKind.STATUS_CHANGED,
summary: failedQa
? `${actor} devolvió ${ticket} desde QA${reason}`
: `${actor} movió ${ticket} a ${to}${reason}`,
});
}
}
if (event instanceof CommentAddedEvent) {
for (const userId of [assigneeId, reporterId]) {
if (!userId) continue;
drafts.push({
userId,
kind: NotificationKind.COMMENTED,
summary: `${actor} comentó en ${issue.key}: «${excerpt(event.comment.body)}»`,
});
}
}
await this.notifier.deliver(
event,
{
projectId: project.id,
projectKey: project.key,
issueId: issue.id,
issueKey: issue.key,
},
drafts,
);
}
}

View File

@@ -0,0 +1,71 @@
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
import { NotificationReadModel } from '@task-stream/api/tracker/domain';
import { NotificationMailer } from '../ports/notification-mailer';
import { NotificationsRepository } from '../ports/notifications.repository';
export interface MyNotifications {
items: NotificationReadModel[];
unread: number;
}
export interface MyNotificationSettings {
/** Copies by e-mail (the user's choice). */
email: boolean;
/** Whether this installation can send e-mail at all. */
emailAvailable: boolean;
}
export class GetMyNotificationsQuery extends Query<MyNotifications> {
constructor(
public readonly userId: string,
public readonly unreadOnly: boolean,
public readonly limit: number,
) {
super();
}
}
export class GetMyNotificationSettingsQuery extends Query<MyNotificationSettings> {
constructor(public readonly userId: string) {
super();
}
}
@QueryHandler(GetMyNotificationsQuery)
export class GetMyNotificationsQueryHandler
implements IQueryHandler<GetMyNotificationsQuery, MyNotifications>
{
constructor(private readonly notifications: NotificationsRepository) {}
async execute(query: GetMyNotificationsQuery): Promise<MyNotifications> {
const [items, unread] = await Promise.all([
this.notifications.findForUser(query.userId, {
unreadOnly: query.unreadOnly,
limit: Math.min(Math.max(query.limit, 1), 100),
}),
this.notifications.countUnread(query.userId),
]);
return { items, unread };
}
}
@QueryHandler(GetMyNotificationSettingsQuery)
export class GetMyNotificationSettingsQueryHandler
implements
IQueryHandler<GetMyNotificationSettingsQuery, MyNotificationSettings>
{
constructor(
private readonly notifications: NotificationsRepository,
private readonly mailer: NotificationMailer,
) {}
async execute(
query: GetMyNotificationSettingsQuery,
): Promise<MyNotificationSettings> {
const settings = await this.notifications.settings(query.userId);
return {
email: this.mailer.enabled && settings.email,
emailAvailable: this.mailer.enabled,
};
}
}

View File

@@ -0,0 +1,176 @@
import {
CommentAddedEvent,
IssueAssignedEvent,
IssueStatusChangedEvent,
NotificationKind,
NotificationReadModel,
NotificationSettingsReadModel,
ProjectMemberAddedEvent,
ProjectRole,
StatusCategory,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
import { FakeUserDirectory } from '../../testing/in-memory';
import { ChangeFeed } from '../ports/change-feed';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { NotificationMailer } from '../ports/notification-mailer';
import { NotificationsRepository } from '../ports/notifications.repository';
import { NotificationsEventHandler } from './notifications.event-handler';
import { Notifier } from './notifier';
class InMemoryNotifications extends NotificationsRepository {
readonly all: NotificationReadModel[] = [];
readonly emailOn = new Set<string>();
async insert(n: NotificationReadModel) {
if (this.all.some((x) => x.id === n.id)) return false;
this.all.push(n);
return true;
}
async findForUser(userId: string) {
return this.all.filter((n) => n.userId === userId);
}
async countUnread(userId: string) {
return this.all.filter((n) => n.userId === userId && !n.readAt).length;
}
async markRead() {
return;
}
async settings(userId: string): Promise<NotificationSettingsReadModel> {
return { userId, email: this.emailOn.has(userId) };
}
async saveSettings() {
return;
}
}
const at = <T extends TrackerEvent>(event: T, version = 2): T =>
Object.assign(event, {
aggregateVersion: version,
occurredAt: '2026-10-08T10:00:00.000Z',
});
function setup(mailEnabled = false) {
const repo = new InMemoryNotifications();
const published: NotificationReadModel[] = [];
const mailed: string[] = [];
const users = new FakeUserDirectory().add('ana').add('bob').add('qa');
const issue = {
id: 'i-1',
key: 'WEB-1',
title: 'Login roto',
reporter: { id: 'qa', name: 'QA' },
assignee: { id: 'bob', name: 'Bob' },
};
const notifier = new Notifier(
repo,
{
publishNotification: async (n: NotificationReadModel) => {
published.push(n);
},
} as unknown as ChangeFeed,
{
enabled: mailEnabled,
send: async (to: { email: string }) => {
mailed.push(to.email);
},
} as unknown as NotificationMailer,
users,
);
const handler = new NotificationsEventHandler(
notifier,
{ findById: async () => issue } as unknown as FindIssuesRepository,
{
findById: async () => ({ id: 'p-1', key: 'WEB', name: 'Web' }),
} as unknown as FindProjectsRepository,
users,
);
return { repo, published, mailed, handler };
}
describe('notifications', () => {
it('tells whoever a ticket is assigned to, not whoever assigned it', async () => {
const { handler, repo, published } = setup();
await handler.handle(
at(new IssueAssignedEvent('ana', 'i-1', 'p-1', 'bob', null)),
);
await handler.handle(
at(new IssueAssignedEvent('ana', 'i-1', 'p-1', 'ana', null), 3),
);
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['bob', NotificationKind.ASSIGNED],
]);
expect(published).toHaveLength(1);
expect(repo.all[0].summary).toContain('WEB-1');
});
it('marks a ticket sent back from QA as such, for whoever works on it and reported it', async () => {
const { handler, repo } = setup();
await handler.handle(
at(
new IssueStatusChangedEvent(
'ana',
'i-1',
'p-1',
'IN_QA',
'QA_FAILED',
'Falla en Safari',
{
fromName: 'En QA',
toName: 'QA fallido',
fromCategory: StatusCategory.QA,
toCategory: StatusCategory.IN_PROGRESS,
},
),
),
);
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['bob', NotificationKind.QA_FAILED],
['qa', NotificationKind.QA_FAILED],
]);
expect(repo.all[0].summary).toContain('Falla en Safari');
});
it('tells about a comment everyone but its author, once even when redelivered', async () => {
const { handler, repo } = setup();
const comment = at(
new CommentAddedEvent('bob', {
id: 'c-1',
issueId: 'i-1',
projectId: 'p-1',
authorId: 'bob',
body: 'Ya está arreglado',
deleted: false,
createdAt: '2026-10-08T10:00:00.000Z',
editedAt: null,
}),
1,
);
await handler.handle(comment);
await handler.handle(comment);
expect(repo.all.map((n) => n.userId)).toEqual(['qa']);
});
it('copies by e-mail only to whoever asked for it, when mail is configured', async () => {
const { handler, repo, mailed } = setup(true);
repo.emailOn.add('bob');
await handler.handle(
at(new ProjectMemberAddedEvent('ana', 'p-1', 'bob', ProjectRole.MEMBER)),
);
await handler.handle(
at(
new ProjectMemberAddedEvent('ana', 'p-1', 'qa', ProjectRole.MEMBER),
3,
),
);
expect(mailed).toEqual(['bob@example.org']);
expect(repo.all.map((n) => n.kind)).toEqual([
NotificationKind.ADDED_TO_PROJECT,
NotificationKind.ADDED_TO_PROJECT,
]);
});
});

View File

@@ -0,0 +1,110 @@
import { Injectable, Logger } from '@nestjs/common';
import {
NotificationKind,
NotificationReadModel,
SYSTEM_ACTOR_ID,
SYSTEM_ACTOR_NAME,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
import { ChangeFeed } from '../ports/change-feed';
import { NotificationMailer } from '../ports/notification-mailer';
import { NotificationsRepository } from '../ports/notifications.repository';
import { UserDirectory } from '../ports/user-directory';
import { versionOf } from '../event-handlers/read-model-notifier';
export interface NotificationDraft {
userId: string;
kind: NotificationKind;
summary: string;
}
export interface NotificationContext {
projectId: string;
projectKey: string;
issueId: string | null;
issueKey: string | null;
}
/**
* Delivers notifications: stored for the recipient, pushed live, and copied
* by e-mail to whoever asked for it. Nobody is told about what they did
* themselves, and each person is told once per event — the first reason wins.
*/
@Injectable()
export class Notifier {
private readonly logger = new Logger(Notifier.name);
constructor(
private readonly notifications: NotificationsRepository,
private readonly changeFeed: ChangeFeed,
private readonly mailer: NotificationMailer,
private readonly users: UserDirectory,
) {}
async deliver(
event: TrackerEvent,
context: NotificationContext,
drafts: NotificationDraft[],
): Promise<void> {
const seen = new Set<string>([event.actorId]);
const wanted = drafts.filter((draft) => {
if (seen.has(draft.userId)) return false;
seen.add(draft.userId);
return true;
});
if (!wanted.length) return;
const refs = await this.users.refs([event.actorId]);
const actor =
event.actorId === SYSTEM_ACTOR_ID
? { id: SYSTEM_ACTOR_ID, name: SYSTEM_ACTOR_NAME }
: refs.of(event.actorId);
const delivered: NotificationReadModel[] = [];
for (const draft of wanted) {
const notification: NotificationReadModel = {
id: `${event.aggregateId}:${versionOf(event)}:${draft.userId}`,
userId: draft.userId,
kind: draft.kind,
...context,
actor,
summary: draft.summary,
readAt: null,
createdAt: event.occurredAt,
};
// Delivered again (at-least-once): already told, nothing to repeat.
if (!(await this.notifications.insert(notification))) continue;
await this.changeFeed.publishNotification(notification);
delivered.push(notification);
}
if (this.mailer.enabled && delivered.length) {
await this.email(delivered);
}
}
/** A failed e-mail never blocks the event stream: it is logged and skipped. */
private async email(notifications: NotificationReadModel[]): Promise<void> {
const contacts = new Map(
(await this.users.contacts(notifications.map((n) => n.userId))).map(
(c) => [c.id, c],
),
);
for (const notification of notifications) {
const contact = contacts.get(notification.userId);
if (!contact?.email) continue;
if (!(await this.notifications.settings(notification.userId)).email)
continue;
try {
await this.mailer.send(
{ name: contact.name, email: contact.email },
notification,
);
} catch (error) {
this.logger.warn(
`E-mail to ${notification.userId} failed: ${(error as Error).message}`,
);
}
}
}
}

View File

@@ -1,6 +1,7 @@
import {
ActivityReadModel,
CommentReadModel,
NotificationReadModel,
IssueReadModel,
ProjectReadModel,
ReleaseReadModel,
@@ -48,6 +49,10 @@ export abstract class ChangeFeed {
notifyUserIds?: string[],
): Promise<void>;
abstract publishNotification(
notification: NotificationReadModel,
): Promise<void>;
abstract issues(projectId: string): AsyncIterableIterator<IssueChange>;
abstract comments(issueId: string): AsyncIterableIterator<CommentChange>;
abstract activity(
@@ -56,4 +61,7 @@ export abstract class ChangeFeed {
abstract releases(projectId: string): AsyncIterableIterator<ReleaseChange>;
abstract project(projectId: string): AsyncIterableIterator<ProjectChange>;
abstract userProjects(userId: string): AsyncIterableIterator<ProjectChange>;
abstract notifications(
userId: string,
): AsyncIterableIterator<NotificationReadModel>;
}

View File

@@ -18,3 +18,5 @@ export * from './upsert-materialized-timeline.repository';
export * from './find-timeline.repository';
export * from './tracker-streams.repository';
export * from './attachment-storage';
export * from './notifications.repository';
export * from './notification-mailer';

View File

@@ -0,0 +1,16 @@
import { NotificationReadModel } from '@task-stream/api/tracker/domain';
export interface MailRecipient {
name: string;
email: string;
}
/** E-mail copies of notifications, for whoever asked for them. */
export abstract class NotificationMailer {
/** False when no mail server is configured: nobody is offered e-mail. */
abstract get enabled(): boolean;
abstract send(
to: MailRecipient,
notification: NotificationReadModel,
): Promise<void>;
}

View File

@@ -0,0 +1,23 @@
import {
NotificationReadModel,
NotificationSettingsReadModel,
} from '@task-stream/api/tracker/domain';
/**
* Notifications are per-person state, not part of any aggregate: they are
* derived from events and read and dismissed by their recipient alone.
*/
export abstract class NotificationsRepository {
/** False when it was already there (the event was delivered again). */
abstract insert(notification: NotificationReadModel): Promise<boolean>;
/** Newest first. */
abstract findForUser(
userId: string,
options: { unreadOnly: boolean; limit: number },
): Promise<NotificationReadModel[]>;
abstract countUnread(userId: string): Promise<number>;
/** `ids` null: all of them. */
abstract markRead(userId: string, ids: string[] | null): Promise<void>;
abstract settings(userId: string): Promise<NotificationSettingsReadModel>;
abstract saveSettings(settings: NotificationSettingsReadModel): Promise<void>;
}

View File

@@ -6,6 +6,12 @@ export interface DirectoryUser {
isActive: boolean;
}
export interface UserContact {
id: string;
name: string;
email: string | null;
}
/** Outbound port to the identity context. */
export abstract class UserDirectory {
/**
@@ -17,6 +23,9 @@ export abstract class UserDirectory {
accessToken?: string,
): Promise<DirectoryUser | null>;
/** Names and e-mail addresses, for the users this application knows. */
abstract contacts(userIds: string[]): Promise<UserContact[]>;
/** Resolves user ids to {id, name} for read models, in one lookup. */
abstract refs(userIds: (string | null | undefined)[]): Promise<UserRefs>;
}

View File

@@ -16,6 +16,27 @@ import { ReadAccess } from './queries/read-access';
import { ReleasesService } from './releases.service';
import { SAGAS } from './sagas/sagas';
import { WorkflowsService } from './workflows.service';
import { NotificationsService } from './notifications.service';
import { Notifier } from './notifications/notifier';
import { NotificationsEventHandler } from './notifications/notifications.event-handler';
import {
GetMyNotificationSettingsQueryHandler,
GetMyNotificationsQueryHandler,
} from './notifications/notifications.queries';
import {
MarkNotificationsReadCommandHandler,
UpdateNotificationSettingsCommandHandler,
} from './notifications/notifications.commands';
/** Notifications: derived from events, kept and dismissed per person. */
const NOTIFICATIONS = [
Notifier,
NotificationsEventHandler,
GetMyNotificationsQueryHandler,
GetMyNotificationSettingsQueryHandler,
MarkNotificationsReadCommandHandler,
UpdateNotificationSettingsCommandHandler,
];
const SERVICES = [
ProjectsService,
@@ -24,6 +45,7 @@ const SERVICES = [
ReleasesService,
ChangesService,
WorkflowsService,
NotificationsService,
];
/**
@@ -43,6 +65,7 @@ const SERVICES = [
...QUERY_HANDLERS,
...EVENT_HANDLERS,
...SAGAS,
...NOTIFICATIONS,
],
exports: SERVICES,
})

View File

@@ -170,6 +170,16 @@ export class FakeUserDirectory extends UserDirectory {
return this.users.get(userId) ?? null;
}
async contacts(userIds: string[]) {
return userIds
.filter((id) => this.users.has(id))
.map((id) => ({
id,
name: this.users.get(id)?.name ?? id,
email: `${id}@example.org`,
}));
}
async refs(): Promise<UserRefs> {
const of = (id: string) => ({ id, name: this.users.get(id)?.name ?? id });
return { of, ofNullable: (id) => (id ? of(id) : null) };

View File

@@ -6,3 +6,4 @@ export * from './release.read-model';
export * from './activity.read-model';
export * from './project-stats.read-model';
export * from './analytics.read-model';
export * from './notification.read-model';

View File

@@ -0,0 +1,46 @@
import { UserRefReadModel } from './user-ref.read-model';
/** Why someone is told. */
export enum NotificationKind {
/** A ticket was assigned to you. */
ASSIGNED = 'ASSIGNED',
/** A ticket you report or work on moved. */
STATUS_CHANGED = 'STATUS_CHANGED',
/** A ticket you work on went back from QA. */
QA_FAILED = 'QA_FAILED',
/** Someone commented on a ticket you report or work on. */
COMMENTED = 'COMMENTED',
/** Someone wrote @you. */
MENTIONED = 'MENTIONED',
/** A ticket you follow changed. */
WATCHED = 'WATCHED',
/** You were added to a project. */
ADDED_TO_PROJECT = 'ADDED_TO_PROJECT',
/** A ticket of yours is due soon or has been still for too long. */
REMINDER = 'REMINDER',
}
/**
* One thing one person should know. Kept per recipient: it has its own read
* state, and nobody reads anyone else's.
*/
export class NotificationReadModel {
/** `<aggregateId>:<version>:<userId>`: the same event never notifies twice. */
id!: string;
userId!: string;
kind!: NotificationKind;
projectId!: string;
projectKey!: string;
issueId!: string | null;
issueKey!: string | null;
actor!: UserRefReadModel;
summary!: string;
readAt!: string | null;
createdAt!: string;
}
/** What a person wants to be told by e-mail as well. */
export class NotificationSettingsReadModel {
userId!: string;
email!: boolean;
}

View File

@@ -6,6 +6,7 @@ import {
} from '@task-stream/api/identity/application';
import {
DirectoryUser,
UserContact,
UserDirectory,
UserRefs,
} from '@task-stream/api/tracker/application';
@@ -29,6 +30,17 @@ export class IdentityUserDirectory extends UserDirectory {
: null;
}
async contacts(userIds: string[]): Promise<UserContact[]> {
const wanted = [...new Set(userIds.filter(Boolean))];
if (!wanted.length) return [];
const users = await this.queryBus.execute(new FindUsersByIdsQuery(wanted));
return users.map((u) => ({
id: u.id,
name: u.name,
email: u.isActive && u.email ? u.email : null,
}));
}
async refs(userIds: (string | null | undefined)[]): Promise<UserRefs> {
const wanted = [...new Set(userIds.filter((id): id is string => !!id))];
const users = wanted.length

View File

@@ -0,0 +1,66 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
MailRecipient,
NotificationMailer,
} from '@task-stream/api/tracker/application';
import { NotificationReadModel } from '@task-stream/api/tracker/domain';
import { createTransport, Transporter } from 'nodemailer';
/**
* E-mail copies through SMTP_URL (`smtps://user:pass@smtp.example.org`).
* Without it nothing is sent and nobody is offered e-mail.
*/
@Injectable()
export class SmtpNotificationMailer extends NotificationMailer {
private readonly logger = new Logger(SmtpNotificationMailer.name);
private readonly transport: Transporter | null;
private readonly from: string;
private readonly webUrl: string;
constructor(config: ConfigService) {
super();
const url = config.get<string>('SMTP_URL');
this.transport = url ? createTransport(url) : null;
this.from =
config.get<string>('MAIL_FROM') || 'TaskStream <no-reply@pireon.dev>';
this.webUrl = (config.get<string>('WEB_URL') || '').replace(/\/+$/, '');
if (this.transport) this.logger.log('E-mail notifications enabled');
}
get enabled(): boolean {
return this.transport !== null;
}
async send(
to: MailRecipient,
notification: NotificationReadModel,
): Promise<void> {
if (!this.transport) return;
const link = this.linkTo(notification);
const subject = notification.issueKey
? `[${notification.issueKey}] ${notification.summary}`
: `[${notification.projectKey}] ${notification.summary}`;
await this.transport.sendMail({
from: this.from,
to: { name: to.name, address: to.email },
subject: subject.length > 160 ? `${subject.slice(0, 159)}…` : subject,
text: [
`Hola ${to.name}:`,
'',
notification.summary,
...(link ? ['', link] : []),
'',
'— TaskStream. Puedes dejar de recibir estos correos desde las notificaciones de la app.',
].join('\n'),
});
}
private linkTo(notification: NotificationReadModel): string | null {
if (!this.webUrl) return null;
const project = encodeURIComponent(notification.projectKey);
return notification.issueKey
? `${this.webUrl}/p/${project}/issues/${encodeURIComponent(notification.issueKey)}`
: `${this.webUrl}/p/${project}`;
}
}

View File

@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { NotificationsRepository } from '@task-stream/api/tracker/application';
import {
NotificationReadModel,
NotificationSettingsReadModel,
} from '@task-stream/api/tracker/domain';
import { Model } from 'mongoose';
import {
fromDoc,
NotificationDoc,
NotificationSettingsDoc,
ReadModels,
} from './read-model.schemas';
@Injectable()
export class MongoNotificationsRepository extends NotificationsRepository {
constructor(
@InjectModel(ReadModels.Notification)
private readonly notifications: Model<NotificationDoc>,
@InjectModel(ReadModels.NotificationSettings)
private readonly settingsDocs: Model<NotificationSettingsDoc>,
) {
super();
}
async insert({ id, ...rest }: NotificationReadModel): Promise<boolean> {
const result = await this.notifications.updateOne(
{ _id: id },
{ $setOnInsert: rest },
{ upsert: true },
);
return result.upsertedCount > 0;
}
async findForUser(
userId: string,
options: { unreadOnly: boolean; limit: number },
): Promise<NotificationReadModel[]> {
const docs = await this.notifications
.find({ userId, ...(options.unreadOnly ? { readAt: null } : {}) })
.sort({ createdAt: -1 })
.limit(options.limit)
.lean<NotificationDoc[]>();
return docs.map((doc) => fromDoc(doc) as NotificationReadModel);
}
countUnread(userId: string): Promise<number> {
return this.notifications.countDocuments({ userId, readAt: null });
}
async markRead(userId: string, ids: string[] | null): Promise<void> {
await this.notifications.updateMany(
{ userId, readAt: null, ...(ids ? { _id: { $in: ids } } : {}) },
{ $set: { readAt: new Date().toISOString() } },
);
}
async settings(userId: string): Promise<NotificationSettingsReadModel> {
const doc = await this.settingsDocs
.findById(userId)
.lean<NotificationSettingsDoc>();
return { userId, email: doc?.email ?? false };
}
async saveSettings(settings: NotificationSettingsReadModel): Promise<void> {
await this.settingsDocs.updateOne(
{ _id: settings.userId },
{ $set: { email: settings.email } },
{ upsert: true },
);
}
}

View File

@@ -1,6 +1,7 @@
import {
ActivityReadModel,
CommentReadModel,
NotificationReadModel,
IssueLinkReadModel,
IssueReadModel,
IssueTimelineEntry,
@@ -23,6 +24,11 @@ export type CommentDoc = Doc<CommentReadModel>;
export type ReleaseDoc = Doc<ReleaseReadModel>;
export type ActivityDoc = Doc<ActivityReadModel>;
export type TimelineDoc = Doc<IssueTimelineEntry>;
export type NotificationDoc = Doc<NotificationReadModel>;
export interface NotificationSettingsDoc {
_id: string;
email: boolean;
}
export const ReadModels = {
Project: 'ProjectReadModel',
@@ -31,6 +37,8 @@ export const ReadModels = {
Release: 'ReleaseReadModel',
Activity: 'ActivityReadModel',
Timeline: 'IssueTimelineReadModel',
Notification: 'NotificationReadModel',
NotificationSettings: 'NotificationSettings',
} as const;
const userRef = { _id: false, id: String, name: String };
@@ -127,6 +135,24 @@ export const TimelineSchema = new Schema<TimelineDoc>(
);
TimelineSchema.index({ projectId: 1, at: 1 });
export const NotificationSchema = new Schema<NotificationDoc>(
{
_id: String,
userId: String,
readAt: String,
createdAt: String,
actor: userRef,
},
{ ...loose, collection: 'notifications' },
);
NotificationSchema.index({ userId: 1, createdAt: -1 });
NotificationSchema.index({ userId: 1, readAt: 1 });
export const NotificationSettingsSchema = new Schema<NotificationSettingsDoc>(
{ _id: String, email: Boolean },
{ ...loose, collection: 'notification_settings' },
);
export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.Project, schema: ProjectSchema },
{ name: ReadModels.Issue, schema: IssueSchema },
@@ -134,6 +160,8 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.Release, schema: ReleaseSchema },
{ name: ReadModels.Activity, schema: ActivitySchema },
{ name: ReadModels.Timeline, schema: TimelineSchema },
{ name: ReadModels.Notification, schema: NotificationSchema },
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
];
export function fromDoc<T extends { _id: string }>(

View File

@@ -7,7 +7,10 @@ import {
ProjectChange,
ReleaseChange,
} from '@task-stream/api/tracker/application';
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
import {
ActivityReadModel,
NotificationReadModel,
} from '@task-stream/api/tracker/domain';
import { RedisPubSub } from 'graphql-redis-subscriptions';
/** Redis pub/sub channels (one per project / issue / user). Works across instances. */
@@ -18,6 +21,7 @@ const Channels = {
release: (projectId: string) => `tracker.release.${projectId}`,
project: (projectId: string) => `tracker.project.${projectId}`,
userProjects: (userId: string) => `tracker.user-projects.${userId}`,
notifications: (userId: string) => `tracker.notifications.${userId}`,
};
@Injectable()
@@ -57,6 +61,17 @@ export class RedisChangeFeed extends ChangeFeed {
);
}
publishNotification(notification: NotificationReadModel): Promise<void> {
return this.pubSub.publish(
Channels.notifications(notification.userId),
notification,
);
}
notifications(userId: string): AsyncIterableIterator<NotificationReadModel> {
return this.pubSub.asyncIterableIterator(Channels.notifications(userId));
}
issues(projectId: string): AsyncIterableIterator<IssueChange> {
return this.pubSub.asyncIterableIterator(Channels.issue(projectId));
}

View File

@@ -5,6 +5,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import {
AttachmentStorage,
ChangeFeed,
NotificationMailer,
NotificationsRepository,
FindActivityRepository,
FindCommentsRepository,
FindIssuesRepository,
@@ -52,6 +54,8 @@ import {
import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repository';
import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
import { RedisChangeFeed } from './realtime/redis-change-feed';
import { MongoNotificationsRepository } from './mongo/notifications.repository';
import { SmtpNotificationMailer } from './mail/smtp-notification.mailer';
import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage';
const PORTS = [
@@ -100,6 +104,9 @@ const PORTS = [
},
// Attached files → disk (ATTACHMENTS_DIR)
{ provide: AttachmentStorage, useClass: FileSystemAttachmentStorage },
// Notifications → MongoDB (per person), copies by e-mail → SMTP (optional)
{ provide: NotificationsRepository, useClass: MongoNotificationsRepository },
{ provide: NotificationMailer, useClass: SmtpNotificationMailer },
// Realtime → Redis pub/sub
{ provide: ChangeFeed, useClass: RedisChangeFeed },
// Identity context (anti-corruption layer)

View File

@@ -2,6 +2,7 @@ import { registerEnumType } from '@nestjs/graphql';
import {
IssueLinkType,
IssuePriority,
NotificationKind,
ProjectRole,
ReleaseStatus,
StatusCategory,
@@ -22,3 +23,7 @@ registerEnumType(IssueLinkType, { name: 'IssueLinkType' });
registerEnumType(ProjectRole, { name: 'ProjectRole' });
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });
registerEnumType(LinkDirection, { name: 'LinkDirection' });
registerEnumType(NotificationKind, {
name: 'NotificationKind',
description: 'Why someone is told',
});

View File

@@ -0,0 +1,50 @@
import { Args, ID, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters';
import { NotificationsService } from '@task-stream/api/tracker/application';
import {
MyNotificationsGql,
NotificationGql,
NotificationSettingsGql,
} from './types';
/** The signed-in person's notifications. */
@Resolver(() => NotificationGql)
export class NotificationResolver {
constructor(private readonly notifications: NotificationsService) {}
@Query(() => MyNotificationsGql, {
description: 'My notifications, newest first, and how many are unread',
})
myNotifications(
@CurrentUser() me: User,
@Args('unreadOnly', { defaultValue: false }) unreadOnly: boolean,
@Args('limit', { type: () => Int, defaultValue: 30 }) limit: number,
) {
return this.notifications.mine(me.id, { unreadOnly, limit });
}
@Query(() => NotificationSettingsGql)
notificationSettings(@CurrentUser() me: User) {
return this.notifications.settings(me.id);
}
@Mutation(() => Boolean, {
description: 'Mark these as read, or all of them when `ids` is omitted',
})
async markNotificationsRead(
@CurrentUser() me: User,
@Args('ids', { type: () => [ID], nullable: true }) ids?: string[] | null,
) {
await this.notifications.markRead(me.id, ids);
return true;
}
@Mutation(() => NotificationSettingsGql)
updateNotificationSettings(
@CurrentUser() me: User,
@Args('email') email: boolean,
) {
return this.notifications.updateSettings(me.id, { email });
}
}

View File

@@ -1,11 +1,15 @@
import { Args, ID, Resolver, Subscription } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters';
import { ChangesService } from '@task-stream/api/tracker/application';
import {
ChangesService,
NotificationsService,
} from '@task-stream/api/tracker/application';
import {
ActivityGql,
CommentChangeGql,
IssueChangeGql,
NotificationGql,
ProjectChangeGql,
ReleaseChangeGql,
} from './types';
@@ -18,7 +22,10 @@ const asIs = { resolve: <T>(payload: T) => payload };
*/
@Resolver()
export class SubscriptionResolver {
constructor(private readonly changes: ChangesService) {}
constructor(
private readonly changes: ChangesService,
private readonly notifications: NotificationsService,
) {}
@Subscription(() => IssueChangeGql, {
...asIs,
@@ -74,4 +81,12 @@ export class SubscriptionResolver {
myProjectsChanged(@CurrentUser() me: User) {
return this.changes.watchMyProjects(me.id);
}
@Subscription(() => NotificationGql, {
...asIs,
description: 'My notifications, as they happen',
})
notificationAdded(@CurrentUser() me: User) {
return this.notifications.watch(me.id);
}
}

View File

@@ -2,6 +2,7 @@ import { Field, Float, ID, Int, ObjectType } from '@nestjs/graphql';
import {
IssueLinkType,
IssuePriority,
NotificationKind,
ProjectRole,
ReleaseStatus,
StatusCategory,
@@ -242,6 +243,37 @@ export class ActivityGql {
@Field() occurredAt!: string;
}
@ObjectType('Notification')
export class NotificationGql {
@Field(() => ID) id!: string;
@Field(() => NotificationKind) kind!: NotificationKind;
@Field(() => ID) projectId!: string;
@Field() projectKey!: string;
@Field(() => ID, { nullable: true }) issueId!: string | null;
@Field(() => String, { nullable: true }) issueKey!: string | null;
@Field(() => UserRefGql) actor!: UserRefGql;
@Field() summary!: string;
@Field(() => String, { nullable: true, description: 'Null: unread' })
readAt!: string | null;
@Field() createdAt!: string;
}
@ObjectType('MyNotifications')
export class MyNotificationsGql {
@Field(() => [NotificationGql]) items!: NotificationGql[];
@Field(() => Int, { description: 'All my unread ones, not just these' })
unread!: number;
}
@ObjectType('NotificationSettings')
export class NotificationSettingsGql {
@Field({ description: 'Copies by e-mail' }) email!: boolean;
@Field({
description: 'Whether this installation can send e-mail at all',
})
emailAvailable!: boolean;
}
@ObjectType('CommandResult', {
description:
'Writes are acknowledged immediately; the read model catches up asynchronously. ' +

View File

@@ -637,3 +637,39 @@ export class PublishReleaseDto {
@IsBoolean()
force = false;
}
// ---- Notifications ----------------------------------------------------------
export class MyNotificationsQueryDto {
@ApiPropertyOptional({ default: false, description: 'Only unread ones' })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
unreadOnly = false;
@ApiPropertyOptional({ type: Number, default: 30, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit = 30;
}
export class MarkNotificationsReadDto {
@ApiPropertyOptional({
type: [String],
description: 'Omit to mark all of them',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(200)
@IsString({ each: true })
ids?: string[];
}
export class NotificationSettingsDto {
@ApiProperty({ description: 'Copies by e-mail' })
@IsBoolean()
email!: boolean;
}

View File

@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IssueLinkType,
IssuePriority,
NotificationKind,
ProjectRole,
ReleaseStatus,
SETTING_COLORS,
@@ -354,3 +355,40 @@ export class ReleaseBurnupDto {
scope!: number[];
@ApiProperty({ type: [Number] }) done!: number[];
}
// ---- Notifications ----------------------------------------------------------
export class NotificationDto {
@ApiProperty() id!: string;
@ApiProperty({ enum: NotificationKind, enumName: 'NotificationKind' })
kind!: NotificationKind;
@ApiProperty({ format: 'uuid' }) projectId!: string;
@ApiProperty({ example: 'WEB' }) projectKey!: string;
@ApiProperty({ type: String, nullable: true, format: 'uuid' })
issueId!: string | null;
@ApiProperty({ type: String, nullable: true, example: 'WEB-12' })
issueKey!: string | null;
@ApiProperty({ type: UserRefDto }) actor!: UserRefDto;
@ApiProperty({ example: 'Ana te asignó WEB-12: Login roto en Safari' })
summary!: string;
@ApiProperty({
type: String,
nullable: true,
format: 'date-time',
description: 'Null: unread',
})
readAt!: string | null;
@ApiProperty({ format: 'date-time' }) createdAt!: string;
}
export class MyNotificationsDto {
@ApiProperty({ type: [NotificationDto] }) items!: NotificationDto[];
@ApiProperty({ description: 'All my unread ones, not just these' })
unread!: number;
}
export class NotificationSettingsResponseDto {
@ApiProperty({ description: 'Copies by e-mail' }) email!: boolean;
@ApiProperty({ description: 'Whether this installation can send e-mail' })
emailAvailable!: boolean;
}

View File

@@ -0,0 +1,70 @@
import { Body, Controller, Get, Post, Put, Query } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
import {
ApiPireonAuth,
CurrentUser,
} from '@task-stream/api/identity/presenters';
import {
ApiCommandErrors,
ApiReadErrors,
} from '@task-stream/api/shared/presenters';
import { NotificationsService } from '@task-stream/api/tracker/application';
import {
MarkNotificationsReadDto,
MyNotificationsQueryDto,
NotificationSettingsDto,
} from './dto/requests.dto';
import {
MyNotificationsDto,
NotificationSettingsResponseDto,
} from './dto/responses.dto';
/** REST adapter of the signed-in person's notifications. */
@ApiTags('Users')
@ApiPireonAuth()
@Controller('me')
export class NotificationsController {
constructor(private readonly notifications: NotificationsService) {}
@Get('notifications')
@ApiOperation({ summary: 'My notifications, newest first' })
@ApiOkResponse({ type: MyNotificationsDto })
@ApiReadErrors()
mine(@CurrentUser() me: User, @Query() query: MyNotificationsQueryDto) {
return this.notifications.mine(me.id, query);
}
@Post('notifications/read')
@ApiOperation({
summary: 'Mark notifications as read (all of them without `ids`)',
})
@ApiOkResponse({ description: 'Done' })
@ApiCommandErrors()
async markRead(
@CurrentUser() me: User,
@Body() body: MarkNotificationsReadDto,
) {
await this.notifications.markRead(me.id, body.ids);
return { ok: true };
}
@Get('notification-settings')
@ApiOperation({ summary: 'Whether I get copies by e-mail' })
@ApiOkResponse({ type: NotificationSettingsResponseDto })
@ApiReadErrors()
settings(@CurrentUser() me: User) {
return this.notifications.settings(me.id);
}
@Put('notification-settings')
@ApiOperation({ summary: 'Turn e-mail copies on or off' })
@ApiOkResponse({ type: NotificationSettingsResponseDto })
@ApiCommandErrors()
updateSettings(
@CurrentUser() me: User,
@Body() body: NotificationSettingsDto,
) {
return this.notifications.updateSettings(me.id, body);
}
}

View File

@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import { MulterModule } from '@nestjs/platform-express';
import './graphql/enums';
import { CommentResolver } from './graphql/comment.resolver';
import { NotificationResolver } from './graphql/notification.resolver';
import { IssueResolver } from './graphql/issue.resolver';
import { ProjectResolver } from './graphql/project.resolver';
import { ReleaseResolver } from './graphql/release.resolver';
@@ -14,6 +15,7 @@ import { ExploreController } from './http/explore.controller';
import { ProjectsController } from './http/projects.controller';
import { Refs } from './http/refs';
import { ReleasesController } from './http/releases.controller';
import { NotificationsController } from './http/notifications.controller';
/**
* Driving adapters of the tracker context: GraphQL (queries, mutations,
@@ -42,6 +44,7 @@ import { ReleasesController } from './http/releases.controller';
CommentsController,
ReleasesController,
ExploreController,
NotificationsController,
],
providers: [
Refs,
@@ -50,6 +53,7 @@ import { ReleasesController } from './http/releases.controller';
CommentResolver,
ReleaseResolver,
SubscriptionResolver,
NotificationResolver,
],
})
export class TrackerPresentersModule {

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