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 main 464 lines 14 kB view raw
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 });