Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import { type Exception } from '@opentelemetry/api';
2import { type Kysely } from 'kysely';
3import _ from 'lodash';
4import { type ReadonlyDeep } from 'type-fest';
5import { uid } from 'uid';
6
7import { inject, type Dependencies } from '../../iocContainer/index.js';
8import { type NonEmptyArray } from '../../utils/typescript-types.js';
9import { CoopEmailAddress } from '../sendEmailService/sendEmailService.js';
10import { type NotificationsServicePg } from './dbTypes.js';
11import { formatNotification } from './notificationFormatter.js';
12
13const { omit } = _;
14
15export enum NotificationType {
16 RulePassRateIncreaseAnomalyStart = 'RULE_PASS_RATE_INCREASE_ANOMALY_START',
17 RulePassRateIncreaseAnomalyEnd = 'RULE_PASS_RATE_INCREASE_ANOMALY_END',
18}
19
20type RuleAnomalyData = {
21 ruleId: string;
22 // We include the ruleName for simplicity so we don't have to query the
23 // `rules` table, which shouldn't need to be accessed from the Notifications
24 // Service.
25 ruleName: string;
26 lastPeriodPassRate: number | undefined;
27 secondToLastPeriodPassRate: number | undefined;
28};
29
30export type NotificationData<T extends NotificationType> = {
31 [NotificationType.RulePassRateIncreaseAnomalyStart]: RuleAnomalyData;
32 [NotificationType.RulePassRateIncreaseAnomalyEnd]: RuleAnomalyData;
33}[T];
34
35// NB: notifications are stored in postgres, but we intentionally don't make a
36// sequelize model, as they should only be accessed through the notification
37// service. My hunch is that these will quickly move out of (the API's)
38// postgress anyway, as notifications tend to end up warranting their own
39// microservice. We also store notifications in postgres with camel cased keys,
40// since there's very little reason not to, and it saves us from needing a
41// key conversion layer.
42export type Notification<T extends NotificationType = NotificationType> = {
43 id: string;
44 type: T;
45 message: string;
46 data: NotificationData<T>;
47 readAt: Date | null;
48 createdAt: Date;
49};
50
51/**
52 * A recipient, as implied by the name, represents _who_ should see a
53 * notification; it has nothing to do with which channels should the
54 * notification be sent to.
55 *
56 * So, if a notification is targeted at one user, there should be one recipient
57 * with `type: user_id`, and that's it, even if the notification should be sent
58 * to that user's email, and show up in the UI, and trigger a push notification.
59 *
60 * The only reason "email_address" is a recipient type is because sometimes an
61 * email address really does pick out a group of people, and only incidentally
62 * specifies a delivery channel. Consider emails like oncall-sre@example.com
63 */
64type Recipient =
65 | { type: 'user_id'; value: string }
66 | { type: 'email_address'; value: string };
67
68type CreateNotificationInput<T extends NotificationType> = Pick<
69 Notification<T>,
70 'type' | 'message' | 'data'
71> & { recipients: Recipient[] };
72
73class NotificationsService {
74 constructor(
75 private readonly query: Kysely<NotificationsServicePg>,
76 private readonly sendEmail: Dependencies['sendEmail'],
77 private readonly configService: Dependencies['ConfigService'],
78 private readonly tracer: Dependencies['Tracer'],
79 ) {}
80
81 async getNotificationsForUser(userId: string): Promise<Notification[]> {
82 return (
83 this.query
84 .selectFrom('notifications')
85 // Don't select userId since it isn't technically part of the
86 // Notification type and we don't want callers to depend on it.
87 .select(['id', 'type', 'message', 'data', 'readAt', 'createdAt'])
88 .where('userId', '=', userId)
89 .execute()
90 );
91 }
92
93 async createNotifications<T extends NotificationType>(
94 notifications: ReadonlyDeep<NonEmptyArray<CreateNotificationInput<T>>>,
95 ) {
96 // Eventually, users might have notification preferences, which we'd
97 // incorporate here to filter who gets which notifications/on what channels,
98 // by notification type.
99 const toUserNotifications = notifications.flatMap((notification) =>
100 notification.recipients
101 .filter((it) => it.type === 'user_id')
102 .map((recipient) => ({
103 id: uid(),
104 userId: recipient.value,
105 ...omit(notification, ['recipients']),
106 })),
107 );
108
109 const emailMessages = notifications
110 .flatMap((notification) =>
111 notification.recipients
112 .filter((recipient) => recipient.type === 'email_address')
113 .map((recipient) => ({
114 ...notification,
115 emailAddress: recipient.value,
116 })),
117 )
118 .map((it) => ({
119 to: it.emailAddress,
120 from: { name: 'Coop', email: CoopEmailAddress.NoReply },
121 subject: it.message,
122 ...formatNotification(it, this.configService.uiUrl),
123 }));
124
125 // Send emails on a best-effort basis, with no retry or logging if one email
126 // gets lost. Ditto for writing the notifications. This is fine for now.
127 await Promise.all([
128 ...emailMessages.map(async (msg) => this.sendEmail(msg).catch(() => {})),
129 this.query
130 .insertInto('notifications')
131 .values(toUserNotifications)
132 .execute()
133 .catch((e) => {
134 const activeSpan = this.tracer.getActiveSpan();
135 if (activeSpan?.isRecording()) {
136 activeSpan.recordException(e as Exception);
137 }
138 }),
139 ]);
140 }
141}
142
143export default inject(
144 ['KyselyPg', 'sendEmail', 'ConfigService', 'Tracer'],
145 NotificationsService,
146);
147export { type NotificationsService };