Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import { type Kysely } from 'kysely';
2import _ from 'lodash';
3import { type JsonObject, type ReadonlyDeep } from 'type-fest';
4
5import { type ConsumerDirectives } from '../../lib/cache/index.js';
6import type { Invoker } from '../../models/types/permissioning.js';
7import { type RuleErrorType, type LocationBankErrorType } from './errors.js';
8import { type ModerationConfigServicePg } from './dbTypes.js';
9import { type Action, type CustomAction, type Policy } from './index.js';
10import ActionOperations, {
11 type ActionErrorType,
12} from './modules/ActionOperations.js';
13import ItemTypeOperations from './modules/ItemTypeOperations.js';
14import MatchingBankOperations, {
15 type MatchingBankErrorType,
16} from './modules/MatchingBankOperations.js';
17import PolicyOperations, {
18 type PolicyErrorType,
19} from './modules/PolicyOperations.js';
20import RuleReadOperations from './modules/RuleReadOperations.js';
21import UserStrikeOperations, {
22 type UserStrikeThresholdErrorType,
23} from './modules/UserStrikeOperations.js';
24import {
25 type ContentItemType,
26 type ItemSchema,
27 type ItemType,
28 type ItemTypeKind,
29 type ItemTypeSelector,
30 type ThreadItemType,
31 type UserItemType,
32} from './types/itemTypes.js';
33import type { PolicyType } from './types/policies.js';
34import { type PlainRuleWithLatestVersion } from '../../models/rules/ruleTypes.js';
35
36export type ModerationConfigErrorType =
37 | 'AttemptingToDeleteDefaultUserType'
38 | ActionErrorType
39 | PolicyErrorType
40 | UserStrikeThresholdErrorType
41 | RuleErrorType
42 | LocationBankErrorType
43 | MatchingBankErrorType;
44
45// By having the ModerationConfigService `implement` this type, TS will check
46// for us that every ModerationConfigService method returns one of our public
47// types.
48type ReturnsModerationConfigTypes = {
49 [K in keyof ModerationConfigService]: ReturnType<
50 ModerationConfigService[K]
51 > extends ArrayOrPromiseOf<void | ItemType | Action | Policy | boolean>
52 ? ModerationConfigService[K]
53 : never;
54};
55
56type ArrayOrPromiseOf<T> =
57 | ReadonlyDeep<T>
58 | readonly ReadonlyDeep<T>[]
59 | Promise<readonly ReadonlyDeep<T>[]>
60 | Promise<ReadonlyDeep<T>>;
61
62type ContentTypeSchemaFieldRoles = {
63 creatorId?: string | null;
64 threadId?: string | null;
65 parentId?: string | null;
66 createdAt?: string | null;
67 displayName?: string | null;
68};
69
70type ThreadTypeSchemaFieldRoles = {
71 createdAt?: string | null;
72 displayName?: string | null;
73 creatorId?: string | null;
74};
75
76type UserTypeSchemaFieldRoles = {
77 profileIcon?: string | null;
78 backgroundImage?: string | null;
79 createdAt?: string | null;
80 displayName?: string | null;
81};
82
83/**
84 * This service will eventually manage all CRUD operations on entities that are
85 * part of an organization's defined moderation policy, including: rules,
86 * actions, policies, item types, and banks — basically, everything in an org
87 * except for the org’s users (which are fairly disconnected).
88 *
89 * The scope of this service is intentionally very broad, and it should not be
90 * sub-divided lightly; see the rationale at
91 * https://coop.atlassian.net/browse/COOP-743?focusedCommentId=10223
92 */
93export class ModerationConfigService implements ReturnsModerationConfigTypes {
94 private readonly actionOps: ActionOperations;
95 private readonly policyOps: PolicyOperations;
96 private readonly itemTypeOps: ItemTypeOperations;
97 private readonly userStrikeOps: UserStrikeOperations;
98 private readonly matchingBankOps: MatchingBankOperations;
99 private readonly ruleReadOps: RuleReadOperations;
100
101 constructor(
102 pgQuery: Kysely<ModerationConfigServicePg>,
103 pgQueryReplica: Kysely<ModerationConfigServicePg>,
104 private readonly onDeletePolicyId: (opts: {
105 policyId: string;
106 orgId: string;
107 }) => Promise<void>,
108 ) {
109 this.actionOps = new ActionOperations(pgQuery, pgQueryReplica);
110 this.policyOps = new PolicyOperations(
111 pgQuery,
112 pgQueryReplica,
113 onDeletePolicyId,
114 );
115 this.itemTypeOps = new ItemTypeOperations(pgQuery, pgQueryReplica);
116 this.userStrikeOps = new UserStrikeOperations(pgQuery, pgQueryReplica);
117 this.matchingBankOps = new MatchingBankOperations(pgQuery, pgQueryReplica);
118 this.ruleReadOps = new RuleReadOperations(pgQuery, pgQueryReplica);
119 }
120
121 async getItemTypes(opts: {
122 orgId: string;
123 directives?: ConsumerDirectives;
124 }): Promise<readonly ReadonlyDeep<ItemType>[]> {
125 return this.itemTypeOps.getItemTypes(opts);
126 }
127
128 async getItemType(opts: {
129 orgId: string;
130 itemTypeSelector: ItemTypeSelector;
131 directives?: ConsumerDirectives;
132 }) {
133 return this.itemTypeOps.getItemType(opts);
134 }
135
136 async getItemTypesByKind<T extends ItemTypeKind>(opts: {
137 orgId: string;
138 kind: T;
139 directives?: ConsumerDirectives;
140 }): Promise<readonly ReadonlyDeep<ItemType & { kind: T }>[]> {
141 return this.itemTypeOps.getItemTypesByKind(opts);
142 }
143
144 async getDefaultUserType(opts: {
145 orgId: string;
146 directives?: ConsumerDirectives;
147 }) {
148 return this.itemTypeOps.getDefaultUserType(opts);
149 }
150
151 async createDefaultUserType(orgId: string) {
152 return this.itemTypeOps.createDefaultUserType(orgId);
153 }
154
155 async createContentType(
156 orgId: string,
157 input: {
158 name: string;
159 schema: ItemSchema;
160 description?: string | null;
161 schemaFieldRoles: ContentTypeSchemaFieldRoles;
162 },
163 ): Promise<ReadonlyDeep<ContentItemType>> {
164 return this.itemTypeOps.createContentType(orgId, input);
165 }
166
167 async updateContentType(
168 orgId: string,
169 input: {
170 id: string;
171 name?: string;
172 schema?: ItemSchema;
173 description?: string | null;
174 schemaFieldRoles: ContentTypeSchemaFieldRoles;
175 },
176 ): Promise<ReadonlyDeep<ContentItemType>> {
177 return this.itemTypeOps.updateContentType(orgId, input);
178 }
179
180 async createThreadType(
181 orgId: string,
182 input: {
183 name: string;
184 schema: ItemSchema;
185 description?: string | null;
186 schemaFieldRoles: ThreadTypeSchemaFieldRoles;
187 },
188 ): Promise<ReadonlyDeep<ThreadItemType>> {
189 return this.itemTypeOps.createThreadType(orgId, input);
190 }
191
192 async updateThreadType(
193 orgId: string,
194 input: {
195 id: string;
196 name?: string;
197 schema?: ItemSchema;
198 description?: string | null;
199 schemaFieldRoles: ThreadTypeSchemaFieldRoles;
200 },
201 ): Promise<ReadonlyDeep<ThreadItemType>> {
202 return this.itemTypeOps.updateThreadType(orgId, input);
203 }
204
205 async createUserType(
206 orgId: string,
207 input: {
208 name: string;
209 schema: ItemSchema;
210 description?: string | null;
211 schemaFieldRoles: UserTypeSchemaFieldRoles;
212 },
213 ): Promise<ReadonlyDeep<UserItemType>> {
214 return this.itemTypeOps.createUserType(orgId, input);
215 }
216
217 async updateUserType(
218 orgId: string,
219 input: {
220 id: string;
221 name?: string;
222 schema?: ItemSchema;
223 description?: string | null;
224 schemaFieldRoles: UserTypeSchemaFieldRoles;
225 },
226 ): Promise<ReadonlyDeep<UserItemType>> {
227 return this.itemTypeOps.updateUserType(orgId, input);
228 }
229
230 async deleteItemType(opts: { orgId: string; itemTypeId: string }) {
231 return this.itemTypeOps.deleteItemType(opts);
232 }
233
234 async getItemTypesForAction(opts: {
235 orgId: string;
236 actionId: string;
237 directives?: ConsumerDirectives;
238 }): Promise<ItemType[]> {
239 return this.itemTypeOps.getItemTypesForAction(opts);
240 }
241
242 async getItemTypesForRule(opts: {
243 orgId: string;
244 ruleId: string;
245 readFromReplica?: boolean;
246 }): Promise<ItemType[]> {
247 return this.itemTypeOps.getItemTypesForRule(opts);
248 }
249
250 async createAction(
251 orgId: string,
252 input: {
253 name: string;
254 description: string | null;
255 // TODO: support other types? Need to figure out relationship between
256 // activating various org settings (e.g., to enable MRT or NCMEC reporting)
257 // and this moderationConfigService.
258 type: 'CUSTOM_ACTION';
259 callbackUrl: string;
260 callbackUrlHeaders: JsonObject | null;
261 callbackUrlBody: JsonObject | null;
262 applyUserStrikes?: boolean;
263 itemTypeIds?: readonly string[];
264 },
265 ): Promise<CustomAction> {
266 return this.actionOps.createAction(orgId, input);
267 }
268
269 async updateCustomAction(
270 orgId: string,
271 opts: {
272 actionId: string;
273 patch: {
274 name?: string;
275 description?: string | null;
276 callbackUrl?: string;
277 callbackUrlHeaders?: JsonObject | null;
278 callbackUrlBody?: JsonObject | null;
279 applyUserStrikes?: boolean;
280 };
281 itemTypeIds?: readonly string[] | undefined;
282 },
283 ): Promise<CustomAction> {
284 return this.actionOps.updateCustomAction({ orgId, ...opts });
285 }
286
287 async deleteCustomAction(opts: { orgId: string; actionId: string }) {
288 return this.actionOps.deleteCustomAction(opts);
289 }
290 async upsertBuiltInActions(orgId: string) {
291 return this.actionOps.upsertBuiltInActions(orgId);
292 }
293
294 async getActions(opts: {
295 orgId: string;
296 ids?: readonly string[];
297 readFromReplica?: boolean;
298 }) {
299 return this.actionOps.getActions(opts);
300 }
301
302 async getActionsForItemType(opts: {
303 orgId: string;
304 itemTypeId: string;
305 itemTypeKind: ItemTypeKind;
306 readFromReplica?: boolean;
307 }) {
308 return this.actionOps.getActionsForItemType(opts);
309 }
310
311 async getActionsForRuleId(opts: {
312 orgId: string;
313 ruleId: string;
314 readFromReplica?: boolean;
315 }) {
316 return this.actionOps.getActionsForRuleId(opts);
317 }
318
319 async getPoliciesByRuleIds(ruleIds: readonly string[]) {
320 return this.policyOps.getPoliciesByRuleIds({
321 ruleIds,
322 readFromReplica: true,
323 });
324 }
325
326 async getEnabledRulesForItemType(itemTypeId: string) {
327 return this.ruleReadOps.getEnabledRulesForItemType(itemTypeId);
328 }
329
330 async getRuleByIdAndOrg(
331 ruleId: string,
332 orgId: string,
333 opts?: { readFromReplica?: boolean },
334 ): Promise<PlainRuleWithLatestVersion | null> {
335 return this.ruleReadOps.getRuleByIdAndOrg(ruleId, orgId, opts);
336 }
337
338 async getRulesForOrg(
339 orgId: string,
340 opts?: { readFromReplica?: boolean },
341 ): Promise<readonly PlainRuleWithLatestVersion[]> {
342 return this.ruleReadOps.getRulesForOrg(orgId, opts);
343 }
344
345 async findEnabledUserRules(): Promise<PlainRuleWithLatestVersion[]> {
346 return this.ruleReadOps.findEnabledUserRules();
347 }
348
349 async getPolicies(opts: { orgId: string; readFromReplica?: boolean }) {
350 return this.policyOps.getPolicies(opts);
351 }
352
353 async getPoliciesByIds(opts: {
354 orgId: string;
355 ids: readonly string[];
356 readFromReplica?: boolean;
357 }) {
358 return this.policyOps.getPoliciesByIds(opts);
359 }
360
361 async getPolicy(opts: {
362 orgId: string;
363 policyId: string;
364 readFromReplica?: boolean;
365 }) {
366 return this.policyOps.getPolicy(opts);
367 }
368
369 async createPolicy(opts: {
370 orgId: string;
371 policy: {
372 name: string;
373 parentId: string | null;
374 policyText: string | null;
375 enforcementGuidelines: string | null;
376 policyType: PolicyType | null;
377 };
378 invokedBy: Invoker;
379 }): Promise<Policy> {
380 return this.policyOps.createPolicy(opts);
381 }
382
383 async updatePolicy(opts: {
384 orgId: string;
385 policy: {
386 id: string;
387 name?: string;
388 parentId?: string | null;
389 policyText?: string | null;
390 enforcementGuidelines?: string | null;
391 policyType?: PolicyType | null;
392 userStrikeCount?: number | null;
393 applyUserStrikeCountConfigToChildren?: boolean | null;
394 };
395 invokedBy: Invoker;
396 }): Promise<Policy> {
397 return this.policyOps.updatePolicy(opts);
398 }
399
400 async deletePolicy(opts: {
401 orgId: string;
402 policyId: string;
403 invokedBy: Invoker;
404 }) {
405 return this.policyOps.deletePolicy(opts);
406 }
407
408 async getUserStrikeThresholdsForOrg(orgId: string) {
409 return this.userStrikeOps.getUserStrikeThresholds({
410 orgId,
411 readFromReplica: true,
412 });
413 }
414
415 async createUserStrikeThreshold(opts: {
416 orgId: string;
417 thresholdSettings: {
418 threshold: number;
419 actions: string[];
420 };
421 }) {
422 return this.userStrikeOps.createUserStrikeThreshold(opts);
423 }
424
425 async setAllUserStrikeThresholds(opts: {
426 orgId: string;
427 thresholds: readonly {
428 threshold: number;
429 actions: readonly string[];
430 }[];
431 }) {
432 return this.userStrikeOps.setAllUserStrikeThresholds(opts);
433 }
434
435 async updateUserStrikeThreshold(opts: {
436 orgId: string;
437 thresholdSettings: { id: string; threshold?: number; actions?: string[] };
438 }) {
439 return this.userStrikeOps.updateUserStrikeThreshold(opts);
440 }
441
442 async deleteUserStrikeThreshold(opts: {
443 orgId: string;
444
445 thresholdSettings: { id: string; threshold: number };
446 }) {
447 return this.userStrikeOps.deleteUserStrikeThreshold({
448 orgId: opts.orgId,
449 id: opts.thresholdSettings.id,
450 threshold: opts.thresholdSettings.threshold,
451 });
452 }
453
454 async getTextBank(opts: {
455 orgId: string;
456 id: string;
457 readFromReplica?: boolean;
458 }) {
459 return this.matchingBankOps.getTextBank(opts);
460 }
461
462 async getTextBanks(opts: { orgId: string; readFromReplica?: boolean }) {
463 return this.matchingBankOps.getTextBanks(opts);
464 }
465
466 async createTextBank(
467 orgId: string,
468 input: {
469 name: string;
470 description: string | null;
471 type: 'STRING' | 'REGEX';
472 ownerId?: string | null;
473 strings: string[];
474 },
475 ) {
476 return this.matchingBankOps.createTextBank(orgId, input);
477 }
478
479 async updateTextBank(
480 orgId: string,
481 input: {
482 id: string;
483 name?: string;
484 description?: string | null;
485 type?: 'STRING' | 'REGEX';
486 ownerId?: string | null;
487 strings?: string[];
488 },
489 ) {
490 return this.matchingBankOps.updateTextBank(orgId, input);
491 }
492
493 async deleteTextBank(orgId: string, id: string) {
494 return this.matchingBankOps.deleteTextBank(orgId, id);
495 }
496 async close() {
497 await this.itemTypeOps.close();
498 }
499}
500