Skip to content

Commit f60f66c

Browse files
authored
Merge pull request #4822 from coralproject/feat/external-notifications-service
create a new external notifications service
2 parents ac17cc4 + f7feebc commit f60f66c

File tree

9 files changed

+292
-11
lines changed

9 files changed

+292
-11
lines changed

server/src/core/server/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,18 @@ const config = convict({
505505
default: ms("3000s"),
506506
env: "NOTIFICATIONS_POLL_RATE",
507507
},
508+
external_notifications_api_url: {
509+
doc: "URL to forward notifications information to an external url.",
510+
format: "url",
511+
default: "http://localhost:7003/api",
512+
env: "EXTERNAL_NOTIFICATIONS_API_URL",
513+
},
514+
external_notifications_api_key: {
515+
doc: "API key to use when forwarding notifications to an external url",
516+
format: String,
517+
default: "",
518+
env: "EXTERNAL_NOTIFICATIONS_API_KEY",
519+
},
508520
});
509521

510522
export type Config = typeof config;

server/src/core/server/graph/context.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { AugmentedRedis } from "coral-server/services/redis";
2929
import { TenantCache } from "coral-server/services/tenant/cache";
3030
import { Request } from "coral-server/types/express";
3131

32+
import { ExternalNotificationsService } from "coral-server/services/notifications/externalService";
3233
import loaders from "./loaders";
3334
import mutators from "./mutators";
3435
import SeenCommentsCollection from "./seenCommentsCollection";
@@ -105,6 +106,7 @@ export default class GraphContext {
105106
public readonly wordList: WordListService;
106107

107108
public readonly notifications: InternalNotificationContext;
109+
public readonly externalNotifications: ExternalNotificationsService;
108110

109111
constructor(options: GraphContextOptions) {
110112
this.id = options.id || uuid();
@@ -155,11 +157,19 @@ export default class GraphContext {
155157
this.config.get("redis_cache_expiry") / 1000
156158
);
157159

160+
this.externalNotifications = new ExternalNotificationsService(
161+
this.config,
162+
this.logger,
163+
this.mongo
164+
);
165+
158166
this.notifications = new InternalNotificationContext(
159167
this.mongo,
160168
this.redis,
161-
this.i18n,
162-
this.logger
169+
this.logger,
170+
// if external notifications are active, we
171+
// turn off the internal notifications
172+
!this.externalNotifications.active()
163173
);
164174
}
165175
}

server/src/core/server/graph/mutators/Comments.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const Comments = (ctx: GraphContext) => ({
6464
ctx.i18n,
6565
ctx.broker,
6666
ctx.notifications,
67+
ctx.externalNotifications,
6768
ctx.tenant,
6869
ctx.user!,
6970
{
@@ -130,6 +131,7 @@ export const Comments = (ctx: GraphContext) => ({
130131
commentID,
131132
commentRevisionID,
132133
},
134+
ctx.externalNotifications,
133135
ctx.now
134136
),
135137
removeReaction: ({

server/src/core/server/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { retrieveAllTenants, retrieveTenant, Tenant } from "./models/tenant";
4949
import { WordListCategory } from "./services/comments/pipeline/phases/wordList/message";
5050
import { WordListService } from "./services/comments/pipeline/phases/wordList/service";
5151
import { ErrorReporter, SentryErrorReporter } from "./services/errors";
52+
import { ExternalNotificationsService } from "./services/notifications/externalService";
5253
import { InternalNotificationContext } from "./services/notifications/internal/context";
5354
import {
5455
isInstalled,
@@ -259,6 +260,12 @@ class Server {
259260
// Prime the tenant cache so it'll be ready to serve now.
260261
await this.tenantCache.primeAll();
261262

263+
const externalNotifications = new ExternalNotificationsService(
264+
this.config,
265+
logger,
266+
this.mongo
267+
);
268+
262269
// Create the Job Queue.
263270
this.tasks = createQueue({
264271
config: this.config,
@@ -267,11 +274,12 @@ class Server {
267274
tenantCache: this.tenantCache,
268275
i18n: this.i18n,
269276
signingConfig: this.signingConfig,
277+
externalNotifications,
270278
notifications: new InternalNotificationContext(
271279
this.mongo,
272280
this.redis,
273-
this.i18n,
274-
logger
281+
logger,
282+
!externalNotifications.active()
275283
),
276284
});
277285

server/src/core/server/queue/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from "coral-server/services/redis";
1717
import { TenantCache } from "coral-server/services/tenant/cache";
1818

19+
import { ExternalNotificationsService } from "coral-server/services/notifications/externalService";
1920
import { ArchiverQueue, createArchiverTask } from "./tasks/archiver";
2021
import { createMailerTask, MailerQueue } from "./tasks/mailer";
2122
import { createNotifierTask, NotifierQueue } from "./tasks/notifier";
@@ -60,6 +61,7 @@ export interface QueueOptions {
6061
signingConfig: JWTSigningConfig;
6162
redis: AugmentedRedis;
6263
notifications: InternalNotificationContext;
64+
externalNotifications: ExternalNotificationsService;
6365
}
6466

6567
export interface TaskQueue {

server/src/core/server/services/comments/actions.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
import { getLatestRevision } from "coral-server/models/comment/helpers";
2626
import { retrieveSite } from "coral-server/models/site";
2727
import { Tenant } from "coral-server/models/tenant";
28-
import { User } from "coral-server/models/user";
28+
import { retrieveUser, User } from "coral-server/models/user";
2929
import { isSiteBanned } from "coral-server/models/user/helpers";
3030
import { AugmentedRedis } from "coral-server/services/redis";
3131
import {
@@ -41,6 +41,7 @@ import {
4141
publishCommentReactionCreated,
4242
} from "../events";
4343
import { I18n } from "../i18n";
44+
import { ExternalNotificationsService } from "../notifications/externalService";
4445
import { submitCommentAsSpam } from "../spam";
4546

4647
export type CreateAction = CreateActionInput;
@@ -320,6 +321,7 @@ export async function createReaction(
320321
tenant: Tenant,
321322
author: User,
322323
input: CreateCommentReaction,
324+
externalNotifications: ExternalNotificationsService,
323325
now = new Date()
324326
) {
325327
const { comment, action } = await addCommentAction(
@@ -353,6 +355,21 @@ export async function createReaction(
353355
).catch((err) => {
354356
logger.error({ err }, "could not publish comment flag created");
355357
});
358+
359+
if (externalNotifications.active()) {
360+
const reccingUser = author;
361+
const reccedUser = comment.authorID
362+
? await retrieveUser(mongo, comment.tenantID, comment.authorID)
363+
: null;
364+
365+
if (reccedUser) {
366+
await externalNotifications.createRec({
367+
from: reccingUser,
368+
to: reccedUser,
369+
comment,
370+
});
371+
}
372+
}
356373
}
357374

358375
return comment;
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import Logger from "bunyan";
2+
import { Config } from "coral-server/config";
3+
import { MongoContext } from "coral-server/data/context";
4+
import { Comment, getLatestRevision } from "coral-server/models/comment";
5+
import { getURLWithCommentID, retrieveStory } from "coral-server/models/story";
6+
import { User } from "coral-server/models/user";
7+
import { convert } from "html-to-text";
8+
import fetch from "node-fetch";
9+
10+
const NotificationSource = "Coral";
11+
const ProfileType = "Coral";
12+
13+
// From Coral input types
14+
15+
interface CreateReplyInput {
16+
from: User;
17+
to: User;
18+
parent: Comment;
19+
reply: Comment;
20+
}
21+
22+
interface CreateRecInput {
23+
from: User;
24+
to: User;
25+
comment: Comment;
26+
}
27+
28+
// To notifications service types
29+
30+
// eslint-disable-next-line no-shadow
31+
enum NotificationType {
32+
CoralRec = "CoralRec",
33+
CoralReply = "CoralReply",
34+
}
35+
36+
interface ExternalUserProfile {
37+
id: string;
38+
type: string;
39+
username?: string;
40+
email?: string;
41+
profileUrl?: string;
42+
avatarUrl?: string;
43+
ssoIds?: string[];
44+
}
45+
46+
interface CommentPayload {
47+
id: string;
48+
storyId: string;
49+
snippet?: string | null;
50+
url?: string | null;
51+
}
52+
53+
const CreateNotificationsMutation = `
54+
mutation CreateNotificationsMutation(
55+
$input: CreateNotificationsInput!
56+
) {
57+
createNotifications(input: $input) {
58+
id
59+
}
60+
}
61+
`;
62+
63+
export class ExternalNotificationsService {
64+
private url?: string | null;
65+
private apiKey?: string | null;
66+
private logger: Logger;
67+
private mongo: MongoContext;
68+
69+
constructor(config: Config, logger: Logger, mongo: MongoContext) {
70+
this.logger = logger;
71+
this.mongo = mongo;
72+
73+
this.url = config.get("external_notifications_api_url");
74+
this.apiKey = config.get("external_notifications_api_key");
75+
}
76+
77+
public active(): boolean {
78+
return !!(this.url && this.apiKey);
79+
}
80+
81+
private userToExternalProfile(user: User): ExternalUserProfile {
82+
return {
83+
id: user.id,
84+
type: ProfileType,
85+
username: user.username,
86+
email: user.email,
87+
profileUrl: user.ssoURL,
88+
avatarUrl: user.avatar,
89+
};
90+
}
91+
92+
private computeSnippetFromComment(comment: Comment): string {
93+
const latestRevision = getLatestRevision(comment);
94+
const formatted = convert(latestRevision.body);
95+
const split = formatted.split(/(\s+)/);
96+
97+
if (split.length < 50) {
98+
return split.join(" ");
99+
} else {
100+
return split.slice(0, 50).join(" ");
101+
}
102+
}
103+
104+
private async commentToPayload(comment: Comment): Promise<CommentPayload> {
105+
const story = await retrieveStory(
106+
this.mongo,
107+
comment.tenantID,
108+
comment.storyID
109+
);
110+
const url = story ? getURLWithCommentID(story.url, comment.id) : null;
111+
112+
return {
113+
id: comment.id,
114+
storyId: comment.storyID,
115+
snippet: this.computeSnippetFromComment(comment),
116+
url,
117+
};
118+
}
119+
120+
public async createRec(input: CreateRecInput) {
121+
if (!this.active()) {
122+
return;
123+
}
124+
125+
try {
126+
const data = {
127+
source: NotificationSource,
128+
type: NotificationType.CoralRec,
129+
from: this.userToExternalProfile(input.from),
130+
to: this.userToExternalProfile(input.to),
131+
storyId: input.comment.storyID,
132+
comment: this.commentToPayload(input.comment),
133+
};
134+
135+
return await this.send(data);
136+
} catch (err) {
137+
this.logger.warn(
138+
{ err, input },
139+
"an error occurred while sending a rec notification"
140+
);
141+
}
142+
143+
return false;
144+
}
145+
146+
public async createReply(input: CreateReplyInput) {
147+
if (!this.active()) {
148+
return;
149+
}
150+
151+
try {
152+
const data = {
153+
source: NotificationSource,
154+
type: NotificationType.CoralReply,
155+
from: this.userToExternalProfile(input.from),
156+
to: this.userToExternalProfile(input.to),
157+
storyId: input.parent.storyID,
158+
comment: await this.commentToPayload(input.parent),
159+
reply: await this.commentToPayload(input.reply),
160+
};
161+
162+
return await this.send(data);
163+
} catch (err) {
164+
this.logger.warn(
165+
{ err, input },
166+
"an error occurred while sending a reply notification"
167+
);
168+
}
169+
170+
return false;
171+
}
172+
173+
private async send(notification: any) {
174+
if (!this.active()) {
175+
return false;
176+
}
177+
178+
const data = {
179+
query: CreateNotificationsMutation,
180+
variables: {
181+
input: {
182+
items: [notification],
183+
},
184+
},
185+
};
186+
187+
const response = await fetch(this.url!, {
188+
method: "POST",
189+
headers: {
190+
"Content-Type": "application/json",
191+
"x-notifications-api-key": this.apiKey!,
192+
},
193+
body: JSON.stringify(data),
194+
});
195+
196+
if (!response.ok) {
197+
this.logger.info(
198+
{ status: response.status, text: await response.text() },
199+
"error while sending external notifications info"
200+
);
201+
202+
return false;
203+
}
204+
205+
return true;
206+
}
207+
}

0 commit comments

Comments
 (0)