Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import { AuthenticationError, ForbiddenError } from 'apollo-server-express';
2import jwt from 'jsonwebtoken';
3
4import {
5 type GQLGetDecisionCountSettings,
6 type GQLGetJobCreationCountSettings,
7 type GQLMutationResolvers,
8 type GQLQueryResolvers,
9 type GQLUserResolvers,
10} from '../generated.js';
11import { gqlSuccessResult } from '../utils/gqlResult.js';
12
13const typeDefs = /* GraphQL */ `
14 enum UserRole {
15 ADMIN
16 RULES_MANAGER
17 ANALYST
18 MODERATOR_MANAGER
19 MODERATOR
20 CHILD_SAFETY_MODERATOR
21 EXTERNAL_MODERATOR
22 }
23
24 enum UserPermission {
25 MANAGE_ORG
26 MUTATE_LIVE_RULES
27 MUTATE_NON_LIVE_RULES
28 RUN_RETROACTION
29 RUN_BACKTEST
30 VIEW_INSIGHTS
31 MANUALLY_ACTION_CONTENT
32 VIEW_MRT
33 VIEW_MRT_DATA
34 EDIT_MRT_QUEUES
35 VIEW_CHILD_SAFETY_DATA
36 MANAGE_POLICIES
37 VIEW_INVESTIGATION
38 VIEW_RULES_DASHBOARD
39 }
40
41 enum UserPenaltySeverity {
42 NONE
43 LOW
44 MEDIUM
45 HIGH
46 SEVERE
47 }
48
49 # TODO: figure out if role can really be null. Also, squash approvedByAdmin
50 # and removedByAdmin into one field for simplicity and to prevent incoherent
51 # states (like being both approved and rejected). Figure out if that new field
52 # can be null.
53 type User {
54 id: ID!
55 email: String!
56 firstName: String!
57 lastName: String!
58 orgId: ID!
59 role: UserRole
60 permissions: [UserPermission!]!
61 createdAt: String!
62 approvedByAdmin: Boolean
63 rejectedByAdmin: Boolean
64 loginMethods: [String!]!
65 # Extra wrapper types here are so that we can eventually turn notifications
66 # into a proper Connection in a non-breaking way if we ever need pagination.
67 notifications: UserNotifications!
68 readMeJWT: String
69 favoriteRules: [Rule!]!
70 favoriteMRTQueues: [ManualReviewQueue!]!
71 interfacePreferences: UserInterfacePreferences!
72 reviewableQueues(queueIds: [ID!]): [ManualReviewQueue!]!
73 }
74
75 type UserInterfacePreferences {
76 moderatorSafetyMuteVideo: Boolean!
77 moderatorSafetyGrayscale: Boolean!
78 moderatorSafetyBlurLevel: Int!
79 mrtChartConfigurations: [ManualReviewChartSettings!]!
80 }
81
82 input ModeratorSafetySettingsInput {
83 moderatorSafetyMuteVideo: Boolean!
84 moderatorSafetyGrayscale: Boolean!
85 moderatorSafetyBlurLevel: Int!
86 }
87
88 input ManualReviewChartConfigurationsInput {
89 chartConfigurations: [ManualReviewChartSettingsInput!]!
90 }
91
92 type UserNotifications {
93 edges: [UserNotificationEdge!]!
94 }
95
96 type UserNotificationEdge {
97 node: Notification!
98 }
99
100 enum NotificationType {
101 RULE_PASS_RATE_INCREASE_ANOMALY_START
102 RULE_PASS_RATE_INCREASE_ANOMALY_END
103 }
104
105 type Notification {
106 id: ID!
107 type: NotificationType!
108 message: String!
109 data: JSONObject
110 readAt: DateTime
111 createdAt: DateTime!
112 }
113
114 type Query {
115 user(id: ID!): User
116 }
117
118 input ChangePasswordInput {
119 currentPassword: String!
120 newPassword: String!
121 }
122
123 type ChangePasswordSuccessResponse {
124 _: Boolean
125 }
126
127 type ChangePasswordError implements Error {
128 title: String!
129 status: Int!
130 type: [String!]!
131 pointer: String
132 detail: String
133 requestId: String
134 }
135
136 union ChangePasswordResponse =
137 ChangePasswordSuccessResponse
138 | ChangePasswordError
139
140 type Mutation {
141 deleteUser(id: ID!): Boolean
142 updateAccountInfo(firstName: String, lastName: String): Boolean
143 changePassword(input: ChangePasswordInput!): ChangePasswordResponse!
144 addFavoriteRule(ruleId: ID!): AddFavoriteRuleSuccessResponse!
145 removeFavoriteRule(ruleId: ID!): RemoveFavoriteRuleSuccessResponse!
146 addFavoriteMRTQueue(queueId: ID!): AddFavoriteMRTQueueSuccessResponse!
147 removeFavoriteMRTQueue(queueId: ID!): RemoveFavoriteMRTQueueSuccessResponse!
148 setModeratorSafetySettings(
149 moderatorSafetySettings: ModeratorSafetySettingsInput!
150 ): SetModeratorSafetySettingsSuccessResponse
151 setMrtChartConfigurationSettings(
152 mrtChartConfigurationSettings: ManualReviewChartConfigurationsInput!
153 ): SetMrtChartConfigurationSettingsSuccessResponse
154 }
155
156 union AddFavoriteRuleResponse = AddFavoriteRuleSuccessResponse
157
158 type AddFavoriteRuleSuccessResponse {
159 _: Boolean
160 }
161
162 type RemoveFavoriteRuleSuccessResponse {
163 _: Boolean
164 }
165
166 type SetModeratorSafetySettingsSuccessResponse {
167 _: Boolean
168 }
169
170 type SetMrtChartConfigurationSettingsSuccessResponse {
171 _: Boolean
172 }
173
174 type AddFavoriteMRTQueueSuccessResponse {
175 _: Boolean
176 }
177
178 type RemoveFavoriteMRTQueueSuccessResponse {
179 _: Boolean
180 }
181
182 union AddFavoriteRuleResponse = AddFavoriteRuleSuccessResponse
183 union RemoveFavoriteRuleResponse = RemoveFavoriteRuleSuccessResponse
184`;
185
186const Query: GQLQueryResolvers = {
187 async user(_, { id }, context) {
188 const user = context.getUser();
189 if (user == null) {
190 throw new AuthenticationError('User required.');
191 }
192
193 const { orgId } = user;
194 return context.dataSources.userAPI.getGraphQLUserFromId({ id, orgId });
195 },
196};
197
198const Mutation: GQLMutationResolvers = {
199 async updateAccountInfo(_, params, context) {
200 const user = context.getUser();
201 if (user == null) {
202 throw new AuthenticationError('Authenticated user required');
203 }
204 await context.dataSources.userAPI.updateAccountInfo(user, params);
205 return true; // TODO: return the updated user instead.
206 },
207 async changePassword(_, params, context) {
208 const user = context.getUser();
209 if (user == null) {
210 throw new AuthenticationError('Authenticated user required');
211 }
212 return context.dataSources.userAPI.changePassword(user, params.input);
213 },
214 async deleteUser(_, params, context) {
215 const user = context.getUser();
216 if (user == null) {
217 throw new AuthenticationError('Authenticated user required');
218 }
219
220 return context.dataSources.userAPI.deleteUser({
221 id: params.id,
222 orgId: user.orgId,
223 });
224 },
225 async addFavoriteRule(_, params, context) {
226 const user = context.getUser();
227 if (user == null) {
228 throw new AuthenticationError('User required.');
229 }
230 await context.dataSources.userAPI.addFavoriteRule(
231 user.id,
232 params.ruleId,
233 user.orgId,
234 );
235 return gqlSuccessResult({}, 'AddFavoriteRuleSuccessResponse');
236 },
237 async removeFavoriteRule(_, params, context) {
238 const user = context.getUser();
239 if (user == null) {
240 throw new AuthenticationError('User required.');
241 }
242 await context.dataSources.userAPI.removeFavoriteRule(
243 user.id,
244 params.ruleId,
245 user.orgId,
246 );
247 return gqlSuccessResult({}, 'RemoveFavoriteRuleSuccessResponse');
248 },
249 async addFavoriteMRTQueue(_, params, context) {
250 const user = context.getUser();
251 if (user == null) {
252 throw new AuthenticationError('User required.');
253 }
254 await context.services.ManualReviewToolService.addFavoriteQueueForUser({
255 userId: user.id,
256 orgId: user.orgId,
257 queueId: params.queueId,
258 });
259 return gqlSuccessResult({}, 'AddFavoriteMRTQueueSuccessResponse');
260 },
261 async removeFavoriteMRTQueue(_, params, context) {
262 const user = context.getUser();
263 if (user == null) {
264 throw new AuthenticationError('User required.');
265 }
266 await context.services.ManualReviewToolService.removeFavoriteQueueForUser({
267 userId: user.id,
268 orgId: user.orgId,
269 queueId: params.queueId,
270 });
271 return gqlSuccessResult({}, 'RemoveFavoriteMRTQueueSuccessResponse');
272 },
273 async setModeratorSafetySettings(_, params, context) {
274 const user = context.getUser();
275 if (user == null) {
276 throw new AuthenticationError('User required.');
277 }
278 await context.services.UserManagementService.upsertUserInterfaceSettings({
279 userId: user.id,
280 userInterfaceSettings: {
281 moderatorSafetySettings: params.moderatorSafetySettings,
282 },
283 });
284 return gqlSuccessResult({}, 'SetModeratorSafetySettingsSuccessResponse');
285 },
286
287 async setMrtChartConfigurationSettings(_, params, context) {
288 const user = context.getUser();
289 if (user == null) {
290 throw new AuthenticationError('User required.');
291 }
292 await context.services.UserManagementService.upsertUserInterfaceSettings({
293 userId: user.id,
294 userInterfaceSettings: {
295 mrtChartConfigurations:
296 params.mrtChartConfigurationSettings.chartConfigurations.map((it) =>
297 it.decisionCountSettings
298 ? {
299 ...it.decisionCountSettings,
300 title: it.title,
301 metric: 'DECISIONS',
302 filterBy: {
303 ...it.decisionCountSettings.filterBy,
304 startDate: new Date(
305 it.decisionCountSettings.filterBy.startDate,
306 ),
307 endDate: new Date(
308 it.decisionCountSettings.filterBy.endDate,
309 ),
310 filteredDecisionActionType: it.decisionCountSettings
311 .filterBy.filteredDecisionActionType
312 ? it.decisionCountSettings.filterBy
313 .filteredDecisionActionType
314 : undefined,
315 },
316 }
317 : {
318 ...it.jobCreationCountSettings!,
319 title: it.title,
320 metric: 'JOBS',
321 },
322 ),
323 },
324 });
325 return gqlSuccessResult(
326 {},
327 'SetMrtChartConfigurationSettingsSuccessResponse',
328 );
329 },
330};
331
332const User: GQLUserResolvers = {
333 permissions(user) {
334 return user.getPermissions();
335 },
336 async notifications(user, _, context) {
337 const api = context.dataSources.notificationsAPI;
338 const notifications = await api.getNotificationsForUser(user.id);
339 return { edges: notifications.map((it) => ({ node: it })) };
340 },
341 async readMeJWT(user, __, { dataSources, getUser }) {
342 try {
343 const authedUser = getUser();
344 if (!authedUser || user.id !== authedUser.id) {
345 throw new ForbiddenError('Must be signed in as this user to read JWT.');
346 }
347
348 const { email, firstName, lastName, orgId } = user;
349 const name = `${firstName} ${lastName}`;
350 const [apiKeyRes, publicSigningKey] = await Promise.all([
351 dataSources.orgAPI.getActivatedApiKeyForOrg(orgId),
352 dataSources.orgAPI.getPublicSigningKeyPem(orgId),
353 ]);
354 const apiKey = apiKeyRes === false ? null : apiKeyRes.key;
355
356 return jwt.sign(
357 { name, email, apiKey, publicSigningKey },
358 process.env.READ_ME_JWT_SECRET!,
359 );
360 } catch (e) {
361 return null;
362 }
363 },
364 async favoriteRules(user, _, context) {
365 return context.dataSources.userAPI.getFavoriteRules(user.id, user.orgId);
366 },
367 async interfacePreferences(user, _, context) {
368 const settings =
369 await context.services.UserManagementService.getUserInterfaceSettings({
370 userId: user.id,
371 orgId: user.orgId,
372 });
373 return {
374 ...settings,
375 mrtChartConfigurations: settings.mrtChartConfigurations.map((it) => {
376 if (!('metric' in it)) {
377 throw new Error('No metric found in MRT chart configuration');
378 }
379 if (it.metric === 'DECISIONS') {
380 return it as GQLGetDecisionCountSettings;
381 } else {
382 return it as GQLGetJobCreationCountSettings;
383 }
384 }),
385 };
386 },
387 async favoriteMRTQueues(user, _, context) {
388 return context.services.ManualReviewToolService.getFavoriteQueuesForUser({
389 userId: user.id,
390 orgId: user.orgId,
391 });
392 },
393 async reviewableQueues(_, { queueIds }, context) {
394 const user = context.getUser();
395 if (user == null) {
396 throw new AuthenticationError('Authenticated user required');
397 }
398
399 const queues =
400 await context.services.ManualReviewToolService.getReviewableQueuesForUser(
401 {
402 invoker: {
403 userId: user.id,
404 permissions: user.getPermissions(),
405 orgId: user.orgId,
406 },
407 },
408 );
409
410 if (queueIds) {
411 return queues.filter((it) => queueIds.includes(it.id));
412 }
413
414 return queues;
415 },
416};
417
418const resolvers = { Query, Mutation, User };
419
420export { typeDefs, resolvers };