Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import { type ConsumerDirectives } from '../../lib/cache/index.js';
2import { makeEnumLike } from '@roostorg/types';
3import { sql, type Kysely, type Transaction } from 'kysely';
4import { type ReadonlyDeep } from 'type-fest';
5import { v1 as uuidv1 } from 'uuid';
6
7import { cached } from '../../utils/caching.js';
8import { filterNullOrUndefined } from '../../utils/collections.js';
9import {
10 CoopError,
11 ErrorType,
12 type ErrorInstanceData,
13} from '../../utils/errors.js';
14import { removeUndefinedKeys } from '../../utils/misc.js';
15import { replaceEmptyStringWithNull } from '../../utils/string.js';
16import {
17 type NonEmptyArray,
18 type NonEmptyString,
19} from '../../utils/typescript-types.js';
20import { type ConditionSet } from '../moderationConfigService/index.js';
21import { type ReportingServicePg } from './dbTypes.js';
22
23export const ReportingRuleStatus = makeEnumLike([
24 'DRAFT',
25 'BACKGROUND',
26 'LIVE',
27 'ARCHIVED',
28]);
29export type ReportingRuleStatus = keyof typeof ReportingRuleStatus;
30
31export type ReportingRule = ReadonlyDeep<{
32 id: string;
33 orgId: string;
34 creatorId: string;
35 name: string;
36 description?: string | null;
37 status: ReportingRuleStatus;
38 itemTypeIds: string[];
39 actionIds: string[];
40 policyIds: string[];
41 conditionSet: ConditionSet;
42 version: string;
43}>;
44export type ReportingRuleWithoutVersion = Omit<ReportingRule, 'version'>;
45
46export type CreateReportingRuleInput = Readonly<{
47 orgId: string;
48 name: string;
49 creatorId: string;
50 description?: string | null;
51 status: ReportingRuleStatus;
52 itemTypeIds: NonEmptyArray<NonEmptyString>;
53 actionIds: readonly string[];
54 policyIds: readonly string[];
55 conditionSet: ConditionSet;
56}>;
57
58export type UpdateReportingRuleInput = Readonly<{
59 id: string;
60 orgId: string;
61 name?: string;
62 description?: string | null;
63 status?: ReportingRuleStatus;
64 itemTypeIds?: NonEmptyArray<NonEmptyString>;
65 actionIds?: readonly string[];
66 policyIds?: readonly string[];
67 conditionSet?: ConditionSet;
68}>;
69
70const reportingRuleSelection = [
71 'id',
72 'org_id as orgId',
73 'creator_id as creatorId',
74 'name',
75 'description',
76 'status',
77 'condition_set as conditionSet',
78] as const;
79
80export default class ReportingRules {
81 private readonly reportingRulesCache;
82
83 constructor(private readonly pgQuery: Kysely<ReportingServicePg>) {
84 this.reportingRulesCache = cached({
85 producer: async (orgId: string) =>
86 this.#getReportingRulesBypassCache(orgId, pgQuery),
87 directives: { freshUntilAge: 10 },
88 });
89 }
90
91 async getReportingRules(opts: {
92 orgId: string;
93 directives?: ConsumerDirectives;
94 }): Promise<ReadonlyDeep<ReportingRule[]>> {
95 const { orgId, directives } = opts;
96 return this.reportingRulesCache(orgId, directives);
97 }
98
99 async createReportingRule(
100 input: CreateReportingRuleInput,
101 ): Promise<ReportingRuleWithoutVersion> {
102 const {
103 orgId,
104 name,
105 description,
106 status,
107 conditionSet,
108 creatorId,
109 itemTypeIds,
110 actionIds,
111 policyIds,
112 } = input;
113
114 return this.pgQuery
115 .transaction()
116 .execute(async (trx) => {
117 const reportingRule = await trx
118 .insertInto('reporting_rules.reporting_rules')
119 .values({
120 id: uuidv1(),
121 org_id: orgId,
122 name,
123 description: replaceEmptyStringWithNull(description),
124 status,
125 condition_set: conditionSet,
126 creator_id: creatorId,
127 })
128 .returning(reportingRuleSelection)
129 .executeTakeFirstOrThrow();
130
131 await trx
132 .insertInto('reporting_rules.reporting_rules_to_item_types')
133 .values(
134 itemTypeIds.map((itemTypeId) => ({
135 reporting_rule_id: reportingRule.id,
136 item_type_id: itemTypeId,
137 })),
138 )
139 .execute();
140
141 await trx
142 .insertInto('reporting_rules.reporting_rules_to_actions')
143 .values(
144 actionIds.map((actionId) => ({
145 action_id: actionId,
146 reporting_rule_id: reportingRule.id,
147 })),
148 )
149 .execute();
150
151 if (policyIds.length > 0) {
152 await trx
153 .insertInto('reporting_rules.reporting_rules_to_policies')
154 .values(
155 policyIds.map((policyId) => ({
156 policy_id: policyId,
157 reporting_rule_id: reportingRule.id,
158 })),
159 )
160 .execute();
161 }
162
163 return {
164 ...reportingRule,
165 itemTypeIds: itemTypeIds as string[],
166 actionIds,
167 policyIds,
168 };
169 })
170 .catch((e) => {
171 throw isReportingRuleNameExistsError(e)
172 ? makeReportingRuleNameExistsError({
173 detail:
174 'The reporting rule was not created because a rule with this name already exists.',
175 cause: e,
176 shouldErrorSpan: true,
177 })
178 : e;
179 });
180 }
181
182 async updateReportingRule(
183 input: UpdateReportingRuleInput,
184 ): Promise<ReportingRuleWithoutVersion> {
185 const {
186 id,
187 orgId,
188 name,
189 description,
190 status,
191 conditionSet,
192 itemTypeIds,
193 actionIds,
194 policyIds,
195 } = input;
196
197 return this.pgQuery
198 .transaction()
199 .execute(async (trx) => {
200 const updatedReportingRule = await trx
201 .updateTable('reporting_rules.reporting_rules')
202 .set({
203 ...(conditionSet ? { condition_set: conditionSet } : {}),
204 ...removeUndefinedKeys({
205 name,
206 description: replaceEmptyStringWithNull(description),
207 status,
208 }),
209 })
210 .where('id', '=', id)
211 .where('org_id', '=', orgId)
212 .returning(reportingRuleSelection)
213 .executeTakeFirstOrThrow();
214
215 if (itemTypeIds) {
216 await trx
217 .deleteFrom('reporting_rules.reporting_rules_to_item_types')
218 .where(({ eb, and }) =>
219 and([
220 eb('reporting_rule_id', '=', id),
221 eb('item_type_id', 'not in', itemTypeIds),
222 ]),
223 )
224 .execute();
225
226 await trx
227 .insertInto('reporting_rules.reporting_rules_to_item_types')
228 .values(
229 itemTypeIds.map((itemTypeId) => ({
230 reporting_rule_id: id,
231 item_type_id: itemTypeId,
232 })),
233 )
234 .onConflict((oc) =>
235 oc.columns(['reporting_rule_id', 'item_type_id']).doNothing(),
236 )
237 .execute();
238 }
239
240 if (actionIds) {
241 await trx
242 .deleteFrom('reporting_rules.reporting_rules_to_actions')
243 .where(({ eb, and }) =>
244 and([
245 eb('reporting_rule_id', '=', id),
246 eb('action_id', 'not in', actionIds),
247 ]),
248 )
249 .execute();
250
251 await trx
252 .insertInto('reporting_rules.reporting_rules_to_actions')
253 .values(
254 actionIds.map((actionId) => ({
255 reporting_rule_id: id,
256 action_id: actionId,
257 })),
258 )
259 .onConflict((oc) =>
260 oc.columns(['reporting_rule_id', 'action_id']).doNothing(),
261 )
262 .execute();
263 }
264
265 if (policyIds) {
266 await trx
267 .deleteFrom('reporting_rules.reporting_rules_to_policies')
268 .where('reporting_rule_id', '=', id)
269 .execute();
270
271 if (policyIds.length > 0) {
272 await trx
273 .insertInto('reporting_rules.reporting_rules_to_policies')
274 .values(
275 policyIds.map((policyId) => ({
276 reporting_rule_id: id,
277 policy_id: policyId,
278 })),
279 )
280 .onConflict((oc) =>
281 oc.columns(['reporting_rule_id', 'policy_id']).doNothing(),
282 )
283 .execute();
284 }
285 }
286
287 return {
288 ...updatedReportingRule,
289 itemTypeIds:
290 itemTypeIds ?? (await this.#getItemTypeIdsForReportingRule(orgId)),
291 actionIds:
292 actionIds ?? (await this.#getActionIdsForReportingRule(orgId)),
293 policyIds:
294 policyIds ?? (await this.#getPolicyIdsForReportingRule(orgId)),
295 };
296 })
297 .catch((e) => {
298 if (isReportingRuleNameExistsError(e)) {
299 throw makeReportingRuleNameExistsError({
300 detail:
301 'The update for this reporting rule was not recorded because the new name already exists as the name of another rule.',
302 cause: e,
303 shouldErrorSpan: true,
304 });
305 }
306
307 if (isReportingRuleNotFoundError(e)) {
308 throw makeReportingRuleNotFoundError({
309 detail: 'The reporting rule does not exist.',
310 cause: e,
311 shouldErrorSpan: true,
312 });
313 }
314
315 throw e;
316 });
317 }
318
319 async deleteReportingRule(input: { id: string }) {
320 const { id } = input;
321
322 return this.pgQuery
323 .transaction()
324 .execute(async (trx) => {
325 await trx
326 .deleteFrom('reporting_rules.reporting_rules_to_item_types')
327 .where('reporting_rule_id', '=', id)
328 .execute();
329 return true;
330 })
331 .catch((_error) => false);
332 }
333
334 async #getReportingRulesBypassCache(
335 orgId: string,
336 db: Transaction<ReportingServicePg> | Kysely<ReportingServicePg>,
337 ): Promise<ReportingRule[]> {
338 const reportingRules = await db
339 .selectFrom('reporting_rules.reporting_rule_versions as reporting_rules')
340 .innerJoin(
341 'reporting_rules.reporting_rules_to_item_types as rules_to_item_types',
342 'reporting_rules.id',
343 'rules_to_item_types.reporting_rule_id',
344 )
345 .innerJoin(
346 'reporting_rules.reporting_rules_to_actions as rules_to_actions',
347 'reporting_rules.id',
348 'rules_to_actions.reporting_rule_id',
349 )
350 .leftJoin(
351 'reporting_rules.reporting_rules_to_policies as rules_to_policies',
352 'reporting_rules.id',
353 'rules_to_policies.reporting_rule_id',
354 )
355 .where('reporting_rules.org_id', '=', orgId)
356 .where('reporting_rules.is_current', '=', true)
357 .select([
358 ...reportingRuleSelection,
359 sql<string[]>`json_agg(distinct rules_to_item_types.item_type_id)`.as(
360 'itemTypeIds',
361 ),
362 sql<string[]>`json_agg(distinct rules_to_actions.action_id)`.as(
363 'actionIds',
364 ),
365 sql<string[]>`json_agg(distinct rules_to_policies.policy_id)`.as(
366 'policyIds',
367 ),
368 'version',
369 ])
370 .groupBy([
371 'reporting_rules.id',
372 'reporting_rules.org_id',
373 'reporting_rules.creator_id',
374 'reporting_rules.name',
375 'reporting_rules.description',
376 'reporting_rules.status',
377 'reporting_rules.condition_set',
378 'reporting_rules.version',
379 ])
380 .execute();
381
382 return reportingRules.map((it) => ({
383 ...it,
384 policyIds: filterNullOrUndefined(it.policyIds),
385 }));
386 }
387
388 async #getItemTypeIdsForReportingRule(
389 reportingRuleId: string,
390 db: Transaction<ReportingServicePg> | Kysely<ReportingServicePg> = this
391 .pgQuery,
392 ) {
393 const results = await db
394 .selectFrom('reporting_rules.reporting_rules_to_item_types')
395 .select('item_type_id as itemTypeId')
396 .where('reporting_rule_id', '=', reportingRuleId)
397 .execute();
398
399 return results.map((row) => row.itemTypeId);
400 }
401
402 async #getActionIdsForReportingRule(
403 reportingRuleId: string,
404 db: Transaction<ReportingServicePg> | Kysely<ReportingServicePg> = this
405 .pgQuery,
406 ) {
407 const results = await db
408 .selectFrom('reporting_rules.reporting_rules_to_actions')
409 .select('action_id as actionId')
410 .where('reporting_rule_id', '=', reportingRuleId)
411 .execute();
412
413 return results.map((row) => row.actionId);
414 }
415
416 async #getPolicyIdsForReportingRule(
417 reportingRuleId: string,
418 db: Transaction<ReportingServicePg> | Kysely<ReportingServicePg> = this
419 .pgQuery,
420 ) {
421 const results = await db
422 .selectFrom('reporting_rules.reporting_rules_to_policies')
423 .select('policy_id as policyId')
424 .where('reporting_rule_id', '=', reportingRuleId)
425 .execute();
426
427 return results.map((row) => row.policyId);
428 }
429}
430
431export type ReportingRuleErrorType =
432 | 'ReportingRuleNameExistsError'
433 | 'NotFoundError';
434
435function isReportingRuleNameExistsError(error: unknown) {
436 return (
437 error instanceof Error &&
438 error.message.includes(
439 'duplicate key value violates unique constraint "reporting_rules_org_id_name_key"',
440 )
441 );
442}
443
444function isReportingRuleNotFoundError(error: unknown) {
445 return error instanceof Error && error.message.includes('no result');
446}
447
448export const makeReportingRuleNameExistsError = (data: ErrorInstanceData) =>
449 new CoopError({
450 status: 400,
451 type: [ErrorType.InvalidUserInput],
452 title: 'A reporting rule with this name already exists.',
453 name: 'ReportingRuleNameExistsError',
454 ...data,
455 });
456
457export const makeReportingRuleNotFoundError = (data: ErrorInstanceData) =>
458 new CoopError({
459 status: 404,
460 type: [ErrorType.InvalidUserInput],
461 title: 'A reporting rule with this ID is not found',
462 name: 'NotFoundError',
463 ...data,
464 });