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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user