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

@@ -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 }>(