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