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:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './notifier';
|
||||
export * from './notifications.queries';
|
||||
export * from './notifications.commands';
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
110
libs/api/tracker/application/src/lib/notifications/notifier.ts
Normal file
110
libs/api/tracker/application/src/lib/notifications/notifier.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. ' +
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user