Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 557ff54b2b435e5f1e789c6a8a4e1bebf2d7deb6 147 lines 5.5 kB view raw
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 };