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.

[Kysely] Migrate Action mutations and lookups to Kysely (#275)

* [Kysely] Migrate Action mutations and lookups to Kysely

* add org match for rules and tests to cover missing bugs

* attempt to make lint happy

* code review fixes

* fix tests using bad org as we create a new org now.

authored by

Juan Mrad and committed by
GitHub
3a759846 44e9ae40

+1048 -192
+4 -4
codegen.yaml
··· 91 91 # Don't change this; it makes type checking too permissive. 92 92 allowParentTypeOverride: false 93 93 mappers: 94 - CustomAction: ../models/rules/ActionModel.js#CustomAction 95 - EnqueueToMrtAction: ../models/rules/ActionModel.js#EnqueueToMrtAction 96 - EnqueueToNcmecAction: ../models/rules/ActionModel.js#EnqueueToNcmecAction 97 - EnqueueAuthorToMrtAction: ../models/rules/ActionModel.js#EnqueueAuthorToMrtAction 94 + CustomAction: ../services/moderationConfigService/types/actions.js#CustomAction 95 + EnqueueToMrtAction: ../services/moderationConfigService/types/actions.js#EnqueueToMrtAction 96 + EnqueueToNcmecAction: ../services/moderationConfigService/types/actions.js#EnqueueToNcmecAction 97 + EnqueueAuthorToMrtAction: ../services/moderationConfigService/types/actions.js#EnqueueAuthorToMrtAction 98 98 Backtest: ../models/rules/BacktestModel.js#Backtest 99 99 ContentType: ../models/rules/ItemTypeModel.js#ItemType 100 100 DerivedFieldSource: ../services/derivedFieldsService/helpers.js#DerivedFieldSpecSource
+54 -88
server/graphql/datasources/ActionApi.ts
··· 1 1 import { type Exception } from '@opentelemetry/api'; 2 2 import pLimit from 'p-limit'; 3 - import { uid } from 'uid'; 4 3 import { v1 as uuidv1 } from 'uuid'; 5 4 6 5 import { inject, type Dependencies } from '../../iocContainer/index.js'; 7 - import { isUniqueConstraintError } from '../../models/errors.js'; 8 - import { 9 - type CollapsedSequelizeAction, 10 - type CustomAction, 11 - type SequelizeAction, 12 - } from '../../models/rules/ActionModel.js'; 13 - import { 14 - ActionType, 15 - type Action, 16 - } from '../../services/moderationConfigService/index.js'; 17 - // TODO: delete the import below when we move the action mutation logic into the 18 - // moderation config service, which is where it should be. 19 - // eslint-disable-next-line import/no-restricted-paths 20 - import { makeActionNameExistsError } from '../../services/moderationConfigService/modules/ActionOperations.js'; 21 6 import { toCorrelationId } from '../../utils/correlationIds.js'; 22 - import { patchInPlace } from '../../utils/misc.js'; 23 - import { type CollapseCases } from '../../utils/typescript-types.js'; 7 + import { makeNotFoundError } from '../../utils/errors.js'; 24 8 import { 25 9 type GQLCreateActionInput, 26 10 type GQLUpdateActionInput, ··· 32 16 class ActionAPI { 33 17 constructor( 34 18 private readonly actionPublisher: Dependencies['ActionPublisher'], 35 - private readonly sequelize: Dependencies['Sequelize'], 19 + private readonly moderationConfigService: Dependencies['ModerationConfigService'], 36 20 private readonly tracer: Dependencies['Tracer'], 37 21 private readonly itemInvestigationService: Dependencies['ItemInvestigationService'], 38 22 private readonly getItemTypeEventuallyConsistent: Dependencies['getItemTypeEventuallyConsistent'], 39 - ) { 40 - } 23 + ) {} 41 24 42 25 async getGraphQLActionFromId(opts: { id: string; orgId: string }) { 43 26 const { id, orgId } = opts; 44 - const action = await this.sequelize.Action.findOne({ 45 - where: { id, orgId }, 46 - rejectOnEmpty: true, 27 + const actions = await this.moderationConfigService.getActions({ 28 + orgId, 29 + ids: [id], 30 + readFromReplica: false, 47 31 }); 48 - 49 - return action satisfies CollapsedSequelizeAction as SequelizeAction; 32 + const action = actions.at(0); 33 + if (action === undefined) { 34 + throw makeNotFoundError('Action not found', { shouldErrorSpan: true }); 35 + } 36 + return action; 50 37 } 51 38 52 39 async getGraphQLActionsFromIds(orgId: string, ids: readonly string[]) { 53 - return (await this.sequelize.Action.findAll({ 54 - where: { orgId, id: ids }, 55 - })) satisfies CollapsedSequelizeAction[] as SequelizeAction[]; 40 + if (ids.length === 0) { 41 + return []; 42 + } 43 + return this.moderationConfigService.getActions({ 44 + orgId, 45 + ids, 46 + readFromReplica: false, 47 + }); 56 48 } 57 49 58 50 async createAction(input: GQLCreateActionInput, orgId: string) { ··· 65 57 callbackUrlBody, 66 58 applyUserStrikes, 67 59 } = input; 68 - const action = this.sequelize.Action.build({ 69 - id: uid(), 60 + 61 + return this.moderationConfigService.createAction(orgId, { 70 62 name, 71 - description, 63 + description: description ?? null, 64 + type: 'CUSTOM_ACTION', 72 65 callbackUrl, 73 - callbackUrlHeaders, 74 - callbackUrlBody, 75 - orgId, 76 - penalty: 'NONE', 77 - applyUserStrikes: applyUserStrikes ?? false, 78 - actionType: ActionType.CUSTOM_ACTION, 79 - appliesToAllItemsOfKind: [], 80 - }) as CustomAction; 81 - 82 - try { 83 - await this.sequelize.transactionWithRetry(async () => { 84 - await action.save(); 85 - await action.addContentTypes([...itemTypeIds]); 86 - await action.save(); 87 - }); 88 - } catch (e: unknown) { 89 - throw isUniqueConstraintError(e) 90 - ? makeActionNameExistsError({ shouldErrorSpan: true }) 91 - : e; 92 - } 93 - 94 - return action; 66 + callbackUrlHeaders: callbackUrlHeaders ?? null, 67 + callbackUrlBody: callbackUrlBody ?? null, 68 + applyUserStrikes: applyUserStrikes ?? undefined, 69 + itemTypeIds, 70 + }); 95 71 } 96 72 97 73 async updateAction(input: GQLUpdateActionInput, orgId: string) { ··· 106 82 applyUserStrikes, 107 83 } = input; 108 84 109 - const action = (await this.sequelize.Action.findOne({ 110 - where: { id, orgId, actionType: ActionType.CUSTOM_ACTION }, 111 - rejectOnEmpty: true, 112 - })) as CustomAction; 113 - patchInPlace(action, { 114 - name: name ?? undefined, 115 - description, 116 - callbackUrl: callbackUrl ?? undefined, 117 - callbackUrlHeaders, 118 - callbackUrlBody, 119 - applyUserStrikes: applyUserStrikes ?? undefined, 85 + return this.moderationConfigService.updateCustomAction(orgId, { 86 + actionId: id, 87 + patch: { 88 + name: name ?? undefined, 89 + description, 90 + callbackUrl: callbackUrl ?? undefined, 91 + callbackUrlHeaders, 92 + callbackUrlBody, 93 + applyUserStrikes: applyUserStrikes ?? undefined, 94 + }, 95 + itemTypeIds: itemTypeIds ?? undefined, 120 96 }); 121 - 122 - try { 123 - await this.sequelize.transactionWithRetry(async () => { 124 - if (itemTypeIds) { 125 - await action.setContentTypes([...itemTypeIds]); 126 - } 127 - await action.save(); 128 - }); 129 - } catch (e: unknown) { 130 - throw isUniqueConstraintError(e) 131 - ? makeActionNameExistsError({ shouldErrorSpan: true }) 132 - : e; 133 - } 134 - 135 - return action; 136 97 } 137 98 138 99 async deleteAction(orgId: string, id: string) { 139 100 try { 140 - const action = await this.sequelize.Action.findOne({ 141 - where: { id, orgId, actionType: ActionType.CUSTOM_ACTION }, 101 + return await this.moderationConfigService.deleteCustomAction({ 102 + orgId, 103 + actionId: id, 142 104 }); 143 - await action?.destroy(); 144 105 } catch (exception) { 145 106 const activeSpan = this.tracer.getActiveSpan(); 146 107 if (activeSpan?.isRecording()) { ··· 149 110 150 111 return false; 151 112 } 152 - return true; 153 113 } 154 114 155 115 async bulkExecuteActions( ··· 162 122 actorEmail: string, 163 123 ) { 164 124 const [actions, policies, itemType] = await Promise.all([ 165 - this.sequelize.Action.findAll({ 166 - where: { id: actionIds, orgId }, 167 - }) satisfies Promise<CollapseCases<Action>[]> as Promise<Action[]>, 168 - this.sequelize.Policy.findAll({ where: { id: policyIds, orgId } }), 125 + this.moderationConfigService.getActions({ 126 + orgId, 127 + ids: actionIds, 128 + readFromReplica: false, 129 + }), 130 + this.moderationConfigService.getPoliciesByIds({ 131 + orgId, 132 + ids: policyIds, 133 + readFromReplica: false, 134 + }), 169 135 this.getItemTypeEventuallyConsistent({ 170 136 orgId, 171 137 typeSelector: { id: itemTypeId }, ··· 246 212 export default inject( 247 213 [ 248 214 'ActionPublisher', 249 - 'Sequelize', 215 + 'ModerationConfigService', 250 216 'Tracer', 251 217 'ItemInvestigationService', 252 218 'getItemTypeEventuallyConsistent',
+6 -1
server/graphql/datasources/RuleApi.ts
··· 501 501 // that are restricted to routing rules only. 502 502 const willHaveActions = actionIds 503 503 ? actionIds.length > 0 504 - : (await this.moderationConfigService.getActionsForRuleId(id)).length > 0; 504 + : ( 505 + await this.moderationConfigService.getActionsForRuleId({ 506 + orgId, 507 + ruleId: id, 508 + }) 509 + ).length > 0; 505 510 506 511 if (willHaveActions && conditionSet) { 507 512 await this.validateSignalsAllowedInAutomatedRules(conditionSet, orgId);
+4 -1
server/graphql/datasources/buildGraphqlRuleParent.ts
··· 42 42 return user; 43 43 }, 44 44 async getActions() { 45 - return deps.moderationConfigService.getActionsForRuleId(plain.id); 45 + return deps.moderationConfigService.getActionsForRuleId({ 46 + orgId: plain.orgId, 47 + ruleId: plain.id, 48 + }); 46 49 }, 47 50 async getPolicies() { 48 51 const byRule = await deps.moderationConfigService.getPoliciesByRuleIds([
+6 -6
server/graphql/generated.ts
··· 18 18 import type { ReportingInsights } from '../graphql/modules/reporting.js'; 19 19 import type { HashBank } from '../models/HashBankModel.js'; 20 20 import type { Org } from '../models/OrgModel.js'; 21 - import type { 22 - CustomAction, 23 - EnqueueAuthorToMrtAction, 24 - EnqueueToMrtAction, 25 - EnqueueToNcmecAction, 26 - } from '../models/rules/ActionModel.js'; 27 21 import type { Backtest } from '../models/rules/BacktestModel.js'; 28 22 import type { ItemType } from '../models/rules/ItemTypeModel.js'; 29 23 import type { ··· 54 48 ConditionSet, 55 49 LeafCondition, 56 50 } from '../services/moderationConfigService/index.js'; 51 + import type { 52 + CustomAction, 53 + EnqueueAuthorToMrtAction, 54 + EnqueueToMrtAction, 55 + EnqueueToNcmecAction, 56 + } from '../services/moderationConfigService/types/actions.js'; 57 57 import type { Notification } from '../services/notificationsService/notificationsService.js'; 58 58 import type { ReportingRuleWithoutVersion } from '../services/reportingService/ReportingRules.js'; 59 59 import type { Signal } from '../services/signalsService/index.js';
+6
server/graphql/modules/action.ts
··· 3 3 import { 4 4 type GQLActionResolvers, 5 5 type GQLCustomActionResolvers, 6 + type GQLCustomMrtApiParamSpec, 6 7 type GQLEnqueueAuthorToMrtActionResolvers, 7 8 type GQLEnqueueToMrtActionResolvers, 8 9 type GQLEnqueueToNcmecActionResolvers, ··· 179 180 }; 180 181 181 182 const CustomAction: GQLCustomActionResolvers = { 183 + customMrtApiParams(parent) { 184 + return Array.isArray(parent.customMrtApiParams) 185 + ? (parent.customMrtApiParams as readonly GQLCustomMrtApiParamSpec[]) 186 + : []; 187 + }, 182 188 async itemTypes(action, _, context) { 183 189 const user = context.getUser(); 184 190 if (user == null) {
+11 -2
server/graphql/modules/contentType.ts
··· 1 1 import { type GQLContentTypeResolvers } from '../generated.js'; 2 + import { unauthenticatedError } from '../utils/errors.js'; 2 3 3 4 const typeDefs = /* GraphQL */ ` 4 5 type ContentType { ··· 12 13 `; 13 14 14 15 const ContentType: GQLContentTypeResolvers = { 15 - async actions(contentType) { 16 - return contentType.getActions(); 16 + async actions(contentType, _, context) { 17 + const user = context.getUser(); 18 + if (user == null || user.orgId !== contentType.orgId) { 19 + throw unauthenticatedError('User required.'); 20 + } 21 + return context.services.ModerationConfigService.getActionsForItemType({ 22 + orgId: contentType.orgId, 23 + itemTypeId: contentType.id, 24 + itemTypeKind: contentType.kind, 25 + }); 17 26 }, 18 27 baseFields(contentType) { 19 28 return contentType.fields;
+4 -1
server/graphql/modules/org.ts
··· 206 206 throw unauthenticatedError('User required.'); 207 207 } 208 208 209 - return org.getActions(); 209 + return context.services.ModerationConfigService.getActions({ 210 + orgId: org.id, 211 + readFromReplica: true, 212 + }); 210 213 }, 211 214 async contentTypes(org, _, context) { 212 215 const user = context.getUser();
+1 -1
server/graphql/modules/policy.ts
··· 1 1 import _ from 'lodash'; 2 2 3 - import { type Policy } from '../../models/PolicyModel.js'; 3 + import { type Policy } from '../../services/moderationConfigService/index.js'; 4 4 import { isCoopErrorOfType } from '../../utils/errors.js'; 5 5 import { 6 6 type GQLMutationDeletePolicyArgs,
+8 -2
server/graphql/modules/rule.ts
··· 659 659 throw unauthenticatedError('Authenticated user required'); 660 660 } 661 661 662 - return rule.getActions(); 662 + return context.services.ModerationConfigService.getActionsForRuleId({ 663 + orgId: user.orgId, 664 + ruleId: rule.id, 665 + }); 663 666 }, 664 667 async policies(rule, _, context) { 665 668 const user = context.getUser(); ··· 709 712 throw unauthenticatedError('Authenticated user required'); 710 713 } 711 714 712 - return rule.getActions(); 715 + return context.services.ModerationConfigService.getActionsForRuleId({ 716 + orgId: user.orgId, 717 + ruleId: rule.id, 718 + }); 713 719 }, 714 720 async policies(rule, _, context) { 715 721 const user = context.getUser();
+2
server/rule_engine/ActionPublisher.test.ts
··· 41 41 id: 'action-1', 42 42 orgId: 'org-123', 43 43 name: 'Action 1', 44 + description: null, 44 45 applyUserStrikes: false, 45 46 penalty: 'NONE' as const, 46 47 actionType: ActionType.CUSTOM_ACTION, ··· 73 74 id: 'action-2', 74 75 orgId: 'org-123', 75 76 name: 'Action 2', 77 + description: null, 76 78 applyUserStrikes: false, 77 79 penalty: 'NONE' as const, 78 80 actionType: ActionType.CUSTOM_ACTION,
+4 -3
server/rule_engine/RuleEngine.ts
··· 233 233 await Promise.all( 234 234 actionableRules.map( 235 235 async (rule) => { 236 - const actions = (await this.getRuleActionsEventuallyConsistent( 237 - rule.id, 238 - )) satisfies readonly ReadonlyDeep<Action>[] as readonly Action[]; 236 + const actions = (await this.getRuleActionsEventuallyConsistent({ 237 + orgId: evaluationContext.org.id, 238 + ruleId: rule.id, 239 + })) satisfies readonly ReadonlyDeep<Action>[] as readonly Action[]; 239 240 240 241 241 242 return [rule, actions] as const;
+11 -2
server/rule_engine/ruleEngineQueries.ts
··· 69 69 ['ModerationConfigService'], 70 70 (moderationConfigService) => { 71 71 return cached({ 72 - async producer(ruleId: string) { 73 - return moderationConfigService.getActionsForRuleId(ruleId); 72 + keyGeneration: { 73 + toString: (key: { orgId: string; ruleId: string }) => 74 + jsonStringify(key), 75 + fromString: (it) => jsonParse(it), 76 + }, 77 + async producer(key: { orgId: string; ruleId: string }) { 78 + return moderationConfigService.getActionsForRuleId({ 79 + orgId: key.orgId, 80 + ruleId: key.ruleId, 81 + readFromReplica: true, 82 + }); 74 83 }, 75 84 directives: { freshUntilAge: 30 }, 76 85 });
+2 -1
server/services/moderationConfigService/dbTypes.ts
··· 1 1 import { type ItemTypeKind } from '@roostorg/types'; 2 2 import { type Generated, type GeneratedAlways } from 'kysely'; 3 - import { type JsonObject } from 'type-fest'; 3 + import { type JsonObject, type JsonValue } from 'type-fest'; 4 4 5 5 import { type TaggedUnionFromCases } from '../../utils/typescript-types.js'; 6 6 import { type ActionType } from './types/actions.js'; ··· 103 103 updated_at: Generated<Date>; 104 104 applies_to_all_items_of_kind: Generated<ItemTypeKind[]>; 105 105 apply_user_strikes: boolean; 106 + custom_mrt_api_params: JsonValue[] | null; 106 107 } & TaggedUnionFromCases< 107 108 { action_type: ActionType }, 108 109 {
+592
server/services/moderationConfigService/moderationConfigService.test.ts
··· 6 6 7 7 import getBottle from '../../iocContainer/index.js'; 8 8 import createOrg from '../../test/fixtureHelpers/createOrg.js'; 9 + import createRule from '../../test/fixtureHelpers/createRule.js'; 9 10 import createUser from '../../test/fixtureHelpers/createUser.js'; 10 11 import { 11 12 makeMockPgDialect, ··· 540 541 "callbackUrl": "https://example.com", 541 542 "callbackUrlBody": null, 542 543 "callbackUrlHeaders": null, 544 + "customMrtApiParams": null, 545 + "description": "Test description", 543 546 "id": Any<String>, 544 547 "name": "Test Action", 545 548 "orgId": Any<String>, ··· 565 568 expect(res).toHaveLength(createdActions.length); 566 569 expect(res).toEqual(expect.arrayContaining(createdActions)); 567 570 }); 571 + 572 + it('should round-trip a non-null customMrtApiParams value', async () => { 573 + const action = await sutWithPrimary.createAction(dummyOrgId, { 574 + name: faker.random.alphaNumeric(), 575 + description: null, 576 + type: 'CUSTOM_ACTION', 577 + callbackUrl: 'https://example.com', 578 + callbackUrlHeaders: null, 579 + callbackUrlBody: null, 580 + }); 581 + 582 + // The service create methods don't expose customMrtApiParams, 583 + // so set it via raw Kysely to exercise the read mapping. 584 + const params = [ 585 + { key: 'foo', value: 'bar' }, 586 + { key: 'baz', value: 'qux' }, 587 + ]; 588 + await container.KyselyPg.updateTable('public.actions') 589 + .set({ custom_mrt_api_params: params }) 590 + .where('id', '=', action.id) 591 + .where('org_id', '=', dummyOrgId) 592 + .execute(); 593 + 594 + try { 595 + const [fetched] = await sutWithPrimary.getActions({ 596 + orgId: dummyOrgId, 597 + ids: [action.id], 598 + }); 599 + expect(fetched).toBeDefined(); 600 + expect(fetched.actionType).toBe('CUSTOM_ACTION'); 601 + // The narrowed CustomAction shape exposes customMrtApiParams. 602 + expect( 603 + (fetched as { customMrtApiParams: unknown }).customMrtApiParams, 604 + ).toEqual(params); 605 + } finally { 606 + await sutWithPrimary.deleteCustomAction({ 607 + orgId: dummyOrgId, 608 + actionId: action.id, 609 + }); 610 + } 611 + }); 568 612 }); 613 + }); 614 + 615 + describe('Update methods', () => { 616 + describe('#updateCustomAction', () => { 617 + const testWithAction = makeTestWithFixture(async () => { 618 + const action = await sutWithPrimary.createAction(dummyOrgId, { 619 + name: faker.random.alphaNumeric(), 620 + description: 'before', 621 + type: 'CUSTOM_ACTION', 622 + callbackUrl: 'https://before.example.com', 623 + callbackUrlHeaders: null, 624 + callbackUrlBody: null, 625 + applyUserStrikes: false, 626 + }); 627 + return { 628 + action, 629 + async cleanup() { 630 + await sutWithPrimary.deleteCustomAction({ 631 + orgId: dummyOrgId, 632 + actionId: action.id, 633 + }); 634 + }, 635 + }; 636 + }); 637 + 638 + testWithAction( 639 + 'should update user-editable fields and bump updated_at', 640 + async ({ action }) => { 641 + const before = await container.KyselyPg.selectFrom('public.actions') 642 + .select(['updated_at']) 643 + .where('id', '=', action.id) 644 + .executeTakeFirstOrThrow(); 645 + 646 + // Wait briefly so updated_at can advance even on fast clocks. 647 + await new Promise((resolve) => setTimeout(resolve, 5)); 648 + 649 + const updated = await sutWithPrimary.updateCustomAction( 650 + dummyOrgId, 651 + { 652 + actionId: action.id, 653 + patch: { 654 + description: 'after', 655 + callbackUrl: 'https://after.example.com', 656 + applyUserStrikes: true, 657 + }, 658 + }, 659 + ); 660 + 661 + expect(updated.actionType).toBe('CUSTOM_ACTION'); 662 + expect(updated.description).toBe('after'); 663 + expect(updated.callbackUrl).toBe('https://after.example.com'); 664 + expect(updated.applyUserStrikes).toBe(true); 665 + 666 + const after = await container.KyselyPg.selectFrom('public.actions') 667 + .select(['updated_at', 'description']) 668 + .where('id', '=', action.id) 669 + .executeTakeFirstOrThrow(); 670 + expect(after.description).toBe('after'); 671 + expect(after.updated_at.getTime()).toBeGreaterThan( 672 + before.updated_at.getTime(), 673 + ); 674 + }, 675 + ); 676 + 677 + testWithAction( 678 + 'should not bump updated_at for an empty patch with no itemTypeIds', 679 + async ({ action }) => { 680 + const before = await container.KyselyPg.selectFrom('public.actions') 681 + .select(['updated_at']) 682 + .where('id', '=', action.id) 683 + .executeTakeFirstOrThrow(); 684 + 685 + await new Promise((resolve) => setTimeout(resolve, 5)); 686 + 687 + const result = await sutWithPrimary.updateCustomAction( 688 + dummyOrgId, 689 + { actionId: action.id, patch: {} }, 690 + ); 691 + 692 + const after = await container.KyselyPg.selectFrom('public.actions') 693 + .select(['updated_at']) 694 + .where('id', '=', action.id) 695 + .executeTakeFirstOrThrow(); 696 + expect(after.updated_at.getTime()).toBe(before.updated_at.getTime()); 697 + expect(result.id).toBe(action.id); 698 + }, 699 + ); 700 + 701 + testWithAction( 702 + 'should throw NotFound when called with the wrong org', 703 + async ({ action }) => { 704 + const otherOrg = await createOrg( 705 + { Org: container.Sequelize.Org }, 706 + container.ModerationConfigService, 707 + container.ApiKeyService, 708 + uid(), 709 + ); 710 + try { 711 + await expect( 712 + sutWithPrimary.updateCustomAction(otherOrg.org.id, { 713 + actionId: action.id, 714 + patch: { description: 'leaked' }, 715 + }), 716 + ).rejects.toThrow( 717 + expect.objectContaining({ type: [ErrorType.NotFound] }), 718 + ); 719 + 720 + // The action's row in the original org must be untouched. 721 + const row = await container.KyselyPg.selectFrom('public.actions') 722 + .select(['description']) 723 + .where('id', '=', action.id) 724 + .executeTakeFirstOrThrow(); 725 + expect(row.description).toBe('before'); 726 + } finally { 727 + await otherOrg.cleanup(); 728 + } 729 + }, 730 + ); 731 + 732 + testWithAction( 733 + 'should reject renaming onto an existing action name', 734 + async ({ action }) => { 735 + const other = await sutWithPrimary.createAction(dummyOrgId, { 736 + name: faker.random.alphaNumeric(), 737 + description: null, 738 + type: 'CUSTOM_ACTION', 739 + callbackUrl: 'https://example.com', 740 + callbackUrlHeaders: null, 741 + callbackUrlBody: null, 742 + }); 743 + try { 744 + await expect( 745 + sutWithPrimary.updateCustomAction(dummyOrgId, { 746 + actionId: action.id, 747 + patch: { name: other.name }, 748 + }), 749 + ).rejects.toThrow( 750 + expect.objectContaining({ 751 + type: [ErrorType.UniqueViolation], 752 + }), 753 + ); 754 + } finally { 755 + await sutWithPrimary.deleteCustomAction({ 756 + orgId: dummyOrgId, 757 + actionId: other.id, 758 + }); 759 + } 760 + }, 761 + ); 762 + 763 + testWithAction( 764 + 'should replace the item-type junction when itemTypeIds is provided', 765 + async ({ action }) => { 766 + const itemTypeA = await sutWithPrimary.createContentType( 767 + dummyOrgId, 768 + { 769 + schema: dummySchema, 770 + description: null, 771 + name: faker.random.alphaNumeric(), 772 + schemaFieldRoles: { displayName: 'fakeField' }, 773 + }, 774 + ); 775 + const itemTypeB = await sutWithPrimary.createContentType( 776 + dummyOrgId, 777 + { 778 + schema: dummySchema, 779 + description: null, 780 + name: faker.random.alphaNumeric(), 781 + schemaFieldRoles: { displayName: 'fakeField' }, 782 + }, 783 + ); 784 + 785 + try { 786 + await sutWithPrimary.updateCustomAction(dummyOrgId, { 787 + actionId: action.id, 788 + patch: {}, 789 + itemTypeIds: [itemTypeA.id], 790 + }); 791 + expect( 792 + await container.KyselyPg.selectFrom( 793 + 'public.actions_and_item_types', 794 + ) 795 + .select(['item_type_id']) 796 + .where('action_id', '=', action.id) 797 + .execute(), 798 + ).toEqual([{ item_type_id: itemTypeA.id }]); 799 + 800 + await sutWithPrimary.updateCustomAction(dummyOrgId, { 801 + actionId: action.id, 802 + patch: {}, 803 + itemTypeIds: [itemTypeB.id], 804 + }); 805 + expect( 806 + await container.KyselyPg.selectFrom( 807 + 'public.actions_and_item_types', 808 + ) 809 + .select(['item_type_id']) 810 + .where('action_id', '=', action.id) 811 + .execute(), 812 + ).toEqual([{ item_type_id: itemTypeB.id }]); 813 + 814 + await sutWithPrimary.updateCustomAction(dummyOrgId, { 815 + actionId: action.id, 816 + patch: {}, 817 + itemTypeIds: [], 818 + }); 819 + expect( 820 + await container.KyselyPg.selectFrom( 821 + 'public.actions_and_item_types', 822 + ) 823 + .select(['item_type_id']) 824 + .where('action_id', '=', action.id) 825 + .execute(), 826 + ).toEqual([]); 827 + } finally { 828 + await sutWithPrimary.deleteItemType({ 829 + orgId: dummyOrgId, 830 + itemTypeId: itemTypeA.id, 831 + }); 832 + await sutWithPrimary.deleteItemType({ 833 + orgId: dummyOrgId, 834 + itemTypeId: itemTypeB.id, 835 + }); 836 + } 837 + }, 838 + ); 839 + }); 840 + }); 841 + 842 + describe('Delete methods', () => { 843 + describe('#deleteCustomAction', () => { 844 + const testWithAction = makeTestWithFixture(async () => { 845 + const action = await sutWithPrimary.createAction(dummyOrgId, { 846 + name: faker.random.alphaNumeric(), 847 + description: null, 848 + type: 'CUSTOM_ACTION', 849 + callbackUrl: 'https://example.com', 850 + callbackUrlHeaders: null, 851 + callbackUrlBody: null, 852 + }); 853 + return { 854 + action, 855 + // Best-effort cleanup; the test under assertion may have already 856 + // removed the row. 857 + async cleanup() { 858 + await sutWithPrimary 859 + .deleteCustomAction({ 860 + orgId: dummyOrgId, 861 + actionId: action.id, 862 + }) 863 + .catch(() => {}); 864 + }, 865 + }; 866 + }); 867 + 868 + testWithAction( 869 + 'should return true and delete the action on success', 870 + async ({ action }) => { 871 + const result = await sutWithPrimary.deleteCustomAction({ 872 + orgId: dummyOrgId, 873 + actionId: action.id, 874 + }); 875 + expect(result).toBe(true); 876 + expect( 877 + await sutWithPrimary.getActions({ 878 + orgId: dummyOrgId, 879 + ids: [action.id], 880 + }), 881 + ).toEqual([]); 882 + }, 883 + ); 884 + 885 + it('should return false when the action does not exist', async () => { 886 + const result = await sutWithPrimary.deleteCustomAction({ 887 + orgId: dummyOrgId, 888 + actionId: uid(), 889 + }); 890 + expect(result).toBe(false); 891 + }); 892 + 893 + testWithAction( 894 + 'should return false when called with the wrong org and leave the row intact', 895 + async ({ action }) => { 896 + const otherOrg = await createOrg( 897 + { Org: container.Sequelize.Org }, 898 + container.ModerationConfigService, 899 + container.ApiKeyService, 900 + uid(), 901 + ); 902 + try { 903 + const result = await sutWithPrimary.deleteCustomAction({ 904 + orgId: otherOrg.org.id, 905 + actionId: action.id, 906 + }); 907 + expect(result).toBe(false); 908 + const [stillThere] = await sutWithPrimary.getActions({ 909 + orgId: dummyOrgId, 910 + ids: [action.id], 911 + }); 912 + expect(stillThere.id).toBe(action.id); 913 + } finally { 914 + await otherOrg.cleanup(); 915 + } 916 + }, 917 + ); 918 + 919 + testWithAction( 920 + 'should clean up rules_and_actions and actions_and_item_types junction rows', 921 + async ({ action }) => { 922 + const itemType = await sutWithPrimary.createContentType( 923 + dummyOrgId, 924 + { 925 + schema: dummySchema, 926 + description: null, 927 + name: faker.random.alphaNumeric(), 928 + schemaFieldRoles: { displayName: 'fakeField' }, 929 + }, 930 + ); 931 + const rule = await createRule(container.Sequelize, dummyOrgId); 932 + 933 + await container.KyselyPg.insertInto( 934 + 'public.actions_and_item_types', 935 + ) 936 + .values({ action_id: action.id, item_type_id: itemType.id }) 937 + .execute(); 938 + await container.KyselyPg.insertInto('public.rules_and_actions') 939 + .values({ action_id: action.id, rule_id: rule.id }) 940 + .execute(); 941 + 942 + try { 943 + const result = await sutWithPrimary.deleteCustomAction({ 944 + orgId: dummyOrgId, 945 + actionId: action.id, 946 + }); 947 + expect(result).toBe(true); 948 + expect( 949 + await container.KyselyPg.selectFrom( 950 + 'public.actions_and_item_types', 951 + ) 952 + .select(['action_id']) 953 + .where('action_id', '=', action.id) 954 + .execute(), 955 + ).toEqual([]); 956 + expect( 957 + await container.KyselyPg.selectFrom('public.rules_and_actions') 958 + .select(['action_id']) 959 + .where('action_id', '=', action.id) 960 + .execute(), 961 + ).toEqual([]); 962 + } finally { 963 + await rule.destroy(); 964 + await sutWithPrimary.deleteItemType({ 965 + orgId: dummyOrgId, 966 + itemTypeId: itemType.id, 967 + }); 968 + } 969 + }, 970 + ); 971 + }); 972 + }); 973 + 974 + describe('#getActionsForItemType', () => { 975 + const testWithItemTypeAndActions = makeTestWithFixture(async () => { 976 + const itemType = await sutWithPrimary.createContentType(dummyOrgId, { 977 + schema: dummySchema, 978 + description: null, 979 + name: faker.random.alphaNumeric(), 980 + schemaFieldRoles: { displayName: 'fakeField' }, 981 + }); 982 + 983 + const viaJunctionAction = await sutWithPrimary.createAction( 984 + dummyOrgId, 985 + { 986 + name: faker.random.alphaNumeric(), 987 + description: null, 988 + type: 'CUSTOM_ACTION', 989 + callbackUrl: 'https://example.com', 990 + callbackUrlHeaders: null, 991 + callbackUrlBody: null, 992 + itemTypeIds: [itemType.id], 993 + }, 994 + ); 995 + 996 + const viaAppliesAllAction = await sutWithPrimary.createAction( 997 + dummyOrgId, 998 + { 999 + name: faker.random.alphaNumeric(), 1000 + description: null, 1001 + type: 'CUSTOM_ACTION', 1002 + callbackUrl: 'https://example.com', 1003 + callbackUrlHeaders: null, 1004 + callbackUrlBody: null, 1005 + }, 1006 + ); 1007 + await container.KyselyPg.updateTable('public.actions') 1008 + .set({ applies_to_all_items_of_kind: ['CONTENT'] }) 1009 + .where('id', '=', viaAppliesAllAction.id) 1010 + .execute(); 1011 + 1012 + // Action satisfying both branches; result should still include it once. 1013 + const viaBothAction = await sutWithPrimary.createAction(dummyOrgId, { 1014 + name: faker.random.alphaNumeric(), 1015 + description: null, 1016 + type: 'CUSTOM_ACTION', 1017 + callbackUrl: 'https://example.com', 1018 + callbackUrlHeaders: null, 1019 + callbackUrlBody: null, 1020 + itemTypeIds: [itemType.id], 1021 + }); 1022 + await container.KyselyPg.updateTable('public.actions') 1023 + .set({ applies_to_all_items_of_kind: ['CONTENT'] }) 1024 + .where('id', '=', viaBothAction.id) 1025 + .execute(); 1026 + 1027 + return { 1028 + itemType, 1029 + viaJunctionAction, 1030 + viaAppliesAllAction, 1031 + viaBothAction, 1032 + async cleanup() { 1033 + await Promise.all( 1034 + [ 1035 + viaJunctionAction.id, 1036 + viaAppliesAllAction.id, 1037 + viaBothAction.id, 1038 + ].map(async (id) => 1039 + sutWithPrimary.deleteCustomAction({ 1040 + orgId: dummyOrgId, 1041 + actionId: id, 1042 + }), 1043 + ), 1044 + ); 1045 + await sutWithPrimary.deleteItemType({ 1046 + orgId: dummyOrgId, 1047 + itemTypeId: itemType.id, 1048 + }); 1049 + }, 1050 + }; 1051 + }); 1052 + 1053 + testWithItemTypeAndActions( 1054 + 'should return actions from both branches, deduped, scoped to the org', 1055 + async ({ 1056 + itemType, 1057 + viaJunctionAction, 1058 + viaAppliesAllAction, 1059 + viaBothAction, 1060 + }) => { 1061 + const result = await sutWithPrimary.getActionsForItemType({ 1062 + orgId: dummyOrgId, 1063 + itemTypeId: itemType.id, 1064 + itemTypeKind: 'CONTENT', 1065 + readFromReplica: false, 1066 + }); 1067 + 1068 + const ids = result.map((it) => it.id).sort(); 1069 + expect(ids).toEqual( 1070 + [ 1071 + viaJunctionAction.id, 1072 + viaAppliesAllAction.id, 1073 + viaBothAction.id, 1074 + ].sort(), 1075 + ); 1076 + 1077 + // Calling with a different org should never surface this org's 1078 + // applies-to-all rows (they'd otherwise leak across orgs since the 1079 + // ANY(...) predicate alone has no tenant scope). 1080 + const otherOrg = await createOrg( 1081 + { Org: container.Sequelize.Org }, 1082 + container.ModerationConfigService, 1083 + container.ApiKeyService, 1084 + uid(), 1085 + ); 1086 + try { 1087 + const otherResult = await sutWithPrimary.getActionsForItemType({ 1088 + orgId: otherOrg.org.id, 1089 + itemTypeId: itemType.id, 1090 + itemTypeKind: 'CONTENT', 1091 + readFromReplica: false, 1092 + }); 1093 + expect(otherResult).toEqual([]); 1094 + } finally { 1095 + await otherOrg.cleanup(); 1096 + } 1097 + }, 1098 + ); 1099 + }); 1100 + 1101 + describe('#getActionsForRuleId', () => { 1102 + const testWithRuleAndAction = makeTestWithFixture(async () => { 1103 + const rule = await createRule(container.Sequelize, dummyOrgId); 1104 + const action = await sutWithPrimary.createAction(dummyOrgId, { 1105 + name: faker.random.alphaNumeric(), 1106 + description: null, 1107 + type: 'CUSTOM_ACTION', 1108 + callbackUrl: 'https://example.com', 1109 + callbackUrlHeaders: null, 1110 + callbackUrlBody: null, 1111 + }); 1112 + await container.KyselyPg.insertInto('public.rules_and_actions') 1113 + .values({ action_id: action.id, rule_id: rule.id }) 1114 + .execute(); 1115 + return { 1116 + rule, 1117 + action, 1118 + async cleanup() { 1119 + await sutWithPrimary.deleteCustomAction({ 1120 + orgId: dummyOrgId, 1121 + actionId: action.id, 1122 + }); 1123 + await rule.destroy(); 1124 + }, 1125 + }; 1126 + }); 1127 + 1128 + testWithRuleAndAction( 1129 + 'should return actions for a rule scoped to the caller org', 1130 + async ({ rule, action }) => { 1131 + const result = await sutWithPrimary.getActionsForRuleId({ 1132 + orgId: dummyOrgId, 1133 + ruleId: rule.id, 1134 + readFromReplica: false, 1135 + }); 1136 + expect(result.map((it) => it.id)).toEqual([action.id]); 1137 + }, 1138 + ); 1139 + 1140 + testWithRuleAndAction( 1141 + 'should not return actions when called with a different org', 1142 + async ({ rule }) => { 1143 + const otherOrg = await createOrg( 1144 + { Org: container.Sequelize.Org }, 1145 + container.ModerationConfigService, 1146 + container.ApiKeyService, 1147 + uid(), 1148 + ); 1149 + try { 1150 + const result = await sutWithPrimary.getActionsForRuleId({ 1151 + orgId: otherOrg.org.id, 1152 + ruleId: rule.id, 1153 + readFromReplica: false, 1154 + }); 1155 + expect(result).toEqual([]); 1156 + } finally { 1157 + await otherOrg.cleanup(); 1158 + } 1159 + }, 1160 + ); 569 1161 }); 570 1162 }); 571 1163
+67 -44
server/services/moderationConfigService/moderationConfigService.ts
··· 6 6 import type { Invoker } from '../../models/types/permissioning.js'; 7 7 import { type RuleErrorType, type LocationBankErrorType } from './errors.js'; 8 8 import { type ModerationConfigServicePg } from './dbTypes.js'; 9 - import { type Action, type Policy } from './index.js'; 9 + import { type Action, type CustomAction, type Policy } from './index.js'; 10 10 import ActionOperations, { 11 11 type ActionErrorType, 12 12 } from './modules/ActionOperations.js'; ··· 58 58 | readonly ReadonlyDeep<T>[] 59 59 | Promise<readonly ReadonlyDeep<T>[]> 60 60 | Promise<ReadonlyDeep<T>>; 61 + 62 + type ContentTypeSchemaFieldRoles = { 63 + creatorId?: string | null; 64 + threadId?: string | null; 65 + parentId?: string | null; 66 + createdAt?: string | null; 67 + displayName?: string | null; 68 + }; 69 + 70 + type ThreadTypeSchemaFieldRoles = { 71 + createdAt?: string | null; 72 + displayName?: string | null; 73 + creatorId?: string | null; 74 + }; 75 + 76 + type UserTypeSchemaFieldRoles = { 77 + profileIcon?: string | null; 78 + backgroundImage?: string | null; 79 + createdAt?: string | null; 80 + displayName?: string | null; 81 + }; 61 82 62 83 /** 63 84 * This service will eventually manage all CRUD operations on entities that are ··· 137 158 name: string; 138 159 schema: ItemSchema; 139 160 description?: string | null; 140 - schemaFieldRoles: { 141 - creatorId?: string | null; 142 - threadId?: string | null; 143 - parentId?: string | null; 144 - createdAt?: string | null; 145 - displayName?: string | null; 146 - }; 161 + schemaFieldRoles: ContentTypeSchemaFieldRoles; 147 162 }, 148 163 ): Promise<ReadonlyDeep<ContentItemType>> { 149 164 return this.itemTypeOps.createContentType(orgId, input); ··· 156 171 name?: string; 157 172 schema?: ItemSchema; 158 173 description?: string | null; 159 - schemaFieldRoles: { 160 - creatorId?: string | null; 161 - threadId?: string | null; 162 - parentId?: string | null; 163 - createdAt?: string | null; 164 - displayName?: string | null; 165 - }; 174 + schemaFieldRoles: ContentTypeSchemaFieldRoles; 166 175 }, 167 176 ): Promise<ReadonlyDeep<ContentItemType>> { 168 177 return this.itemTypeOps.updateContentType(orgId, input); ··· 174 183 name: string; 175 184 schema: ItemSchema; 176 185 description?: string | null; 177 - schemaFieldRoles: { 178 - createdAt?: string | null; 179 - displayName?: string | null; 180 - creatorId?: string | null; 181 - }; 186 + schemaFieldRoles: ThreadTypeSchemaFieldRoles; 182 187 }, 183 188 ): Promise<ReadonlyDeep<ThreadItemType>> { 184 189 return this.itemTypeOps.createThreadType(orgId, input); ··· 191 196 name?: string; 192 197 schema?: ItemSchema; 193 198 description?: string | null; 194 - schemaFieldRoles: { 195 - createdAt?: string | null; 196 - displayName?: string | null; 197 - creatorId?: string | null; 198 - }; 199 + schemaFieldRoles: ThreadTypeSchemaFieldRoles; 199 200 }, 200 201 ): Promise<ReadonlyDeep<ThreadItemType>> { 201 202 return this.itemTypeOps.updateThreadType(orgId, input); ··· 207 208 name: string; 208 209 schema: ItemSchema; 209 210 description?: string | null; 210 - schemaFieldRoles: { 211 - profileIcon?: string | null; 212 - backgroundImage?: string | null; 213 - createdAt?: string | null; 214 - displayName?: string | null; 215 - }; 211 + schemaFieldRoles: UserTypeSchemaFieldRoles; 216 212 }, 217 213 ): Promise<ReadonlyDeep<UserItemType>> { 218 214 return this.itemTypeOps.createUserType(orgId, input); ··· 225 221 name?: string; 226 222 schema?: ItemSchema; 227 223 description?: string | null; 228 - schemaFieldRoles: { 229 - profileIcon?: string | null; 230 - backgroundImage?: string | null; 231 - createdAt?: string | null; 232 - displayName?: string | null; 233 - }; 224 + schemaFieldRoles: UserTypeSchemaFieldRoles; 234 225 }, 235 226 ): Promise<ReadonlyDeep<UserItemType>> { 236 227 return this.itemTypeOps.updateUserType(orgId, input); ··· 268 259 callbackUrl: string; 269 260 callbackUrlHeaders: JsonObject | null; 270 261 callbackUrlBody: JsonObject | null; 271 - // TODO: linking specific item types not yet supported. 272 262 applyUserStrikes?: boolean; 263 + itemTypeIds?: readonly string[]; 273 264 }, 274 - ) { 265 + ): Promise<CustomAction> { 275 266 return this.actionOps.createAction(orgId, input); 276 267 } 277 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 + 278 291 async getActions(opts: { 279 292 orgId: string; 280 293 ids?: readonly string[]; ··· 283 296 return this.actionOps.getActions(opts); 284 297 } 285 298 286 - async getActionsForRuleId(ruleId: string) { 287 - return this.actionOps.getActionsForRuleId({ 288 - ruleId, 289 - readFromReplica: true, 290 - }); 299 + async getActionsForItemType(opts: { 300 + orgId: string; 301 + itemTypeId: string; 302 + itemTypeKind: ItemTypeKind; 303 + readFromReplica?: boolean; 304 + }) { 305 + return this.actionOps.getActionsForItemType(opts); 306 + } 307 + 308 + async getActionsForRuleId(opts: { 309 + orgId: string; 310 + ruleId: string; 311 + readFromReplica?: boolean; 312 + }) { 313 + return this.actionOps.getActionsForRuleId(opts); 291 314 } 292 315 293 316 async getPoliciesByRuleIds(ruleIds: readonly string[]) {
+244 -32
server/services/moderationConfigService/modules/ActionOperations.ts
··· 1 - import { type Kysely } from 'kysely'; 2 - import { type JsonObject, type Writable } from 'type-fest'; 1 + import { type Kysely, sql } from 'kysely'; 2 + import { type JsonObject, type JsonValue, type Writable } from 'type-fest'; 3 3 import { uid } from 'uid'; 4 4 5 5 import { 6 6 CoopError, 7 7 ErrorType, 8 + makeNotFoundError, 8 9 type ErrorInstanceData, 9 10 } from '../../../utils/errors.js'; 10 11 import { 12 + isUniqueViolationError, 11 13 type FixKyselyRowCorrelation, 12 - 13 14 } from '../../../utils/kysely.js'; 14 - import { assertUnreachable } from '../../../utils/misc.js'; 15 + import { makeKyselyTransactionWithRetry } from '../../../utils/kyselyTransactionWithRetry.js'; 16 + import { assertUnreachable, removeUndefinedKeys } from '../../../utils/misc.js'; 15 17 import { type ModerationConfigServicePg } from '../dbTypes.js'; 16 - import { type Action } from '../index.js'; 18 + import { type Action, type CustomAction } from '../index.js'; 19 + import { type ItemTypeKind } from '../types/itemTypes.js'; 20 + 21 + function assertCustomAction(action: Action): asserts action is CustomAction { 22 + if (action.actionType !== 'CUSTOM_ACTION') { 23 + throw new Error( 24 + `Expected CUSTOM_ACTION but received ${action.actionType}`, 25 + ); 26 + } 27 + } 17 28 18 29 const actionDbSelection = [ 19 30 'id', ··· 27 38 'action_type as actionType', 28 39 'applies_to_all_items_of_kind as appliesToAllItemsOfKind', 29 40 'apply_user_strikes as applyUserStrikes', 41 + 'custom_mrt_api_params as customMrtApiParams', 30 42 ] as const; 31 43 32 44 const actionJoinDbSelection = [ ··· 41 53 'a.action_type as actionType', 42 54 'a.applies_to_all_items_of_kind as appliesToAllItemsOfKind', 43 55 'a.apply_user_strikes as applyUserStrikes', 56 + 'a.custom_mrt_api_params as customMrtApiParams', 44 57 ] as const; 45 58 46 59 type ActionDbResult = FixKyselyRowCorrelation< ··· 49 62 >; 50 63 51 64 export default class ActionOperations { 65 + private readonly transactionWithRetry: ReturnType< 66 + typeof makeKyselyTransactionWithRetry<ModerationConfigServicePg> 67 + >; 68 + 52 69 constructor( 53 70 private readonly pgQuery: Kysely<ModerationConfigServicePg>, 54 71 private readonly pgQueryReplica: Kysely<ModerationConfigServicePg>, 55 - ) {} 72 + ) { 73 + this.transactionWithRetry = makeKyselyTransactionWithRetry(this.pgQuery); 74 + } 56 75 57 76 async createAction( 58 77 orgId: string, ··· 67 86 callbackUrlHeaders: JsonObject | null; 68 87 callbackUrlBody: JsonObject | null; 69 88 applyUserStrikes?: boolean; 70 - // TODO: linking specific item types not yet supported. 89 + itemTypeIds?: readonly string[]; 71 90 }, 72 - ) { 73 - return this.pgQuery.transaction().execute(async (trx) => { 74 - const query = trx 75 - .insertInto('public.actions') 76 - .values({ 77 - id: uid(), 78 - name: input.name, 79 - description: input.description, 80 - org_id: orgId, 81 - action_type: input.type, 82 - callback_url: input.callbackUrl, 83 - callback_url_headers: input.callbackUrlHeaders, 84 - callback_url_body: input.callbackUrlBody, 85 - penalty: 'NONE', 86 - apply_user_strikes: input.applyUserStrikes ?? false, 87 - }) 88 - .returning(actionDbSelection); 89 - 90 - // eslint-disable-next-line no-useless-catch 91 + ): Promise<CustomAction> { 92 + return this.transactionWithRetry(async (trx) => { 91 93 try { 94 + const query = trx 95 + .insertInto('public.actions') 96 + .values({ 97 + id: uid(), 98 + name: input.name, 99 + description: input.description, 100 + org_id: orgId, 101 + action_type: input.type, 102 + callback_url: input.callbackUrl, 103 + callback_url_headers: input.callbackUrlHeaders, 104 + callback_url_body: input.callbackUrlBody, 105 + penalty: 'NONE', 106 + apply_user_strikes: input.applyUserStrikes ?? false, 107 + updated_at: new Date(), 108 + }) 109 + .returning(actionDbSelection); 110 + 92 111 const actionRow = 93 112 (await query.executeTakeFirstOrThrow()) as ActionDbResult; 94 113 95 - return this.#dbResultToAction(actionRow); 96 - } catch (e) { 97 - // TODO: catch specific error for duplicate action name and call 98 - // makeActionNameExistsError and throw that error instead. 114 + if (input.itemTypeIds !== undefined && input.itemTypeIds.length > 0) { 115 + await trx 116 + .insertInto('public.actions_and_item_types') 117 + .values( 118 + input.itemTypeIds.map((item_type_id) => ({ 119 + action_id: actionRow.id, 120 + item_type_id, 121 + })), 122 + ) 123 + .execute(); 124 + } 125 + 126 + const action = this.#dbResultToAction(actionRow); 127 + assertCustomAction(action); 128 + return action; 129 + } catch (e: unknown) { 130 + if (isUniqueViolationError(e)) { 131 + throw makeActionNameExistsError({ shouldErrorSpan: true }); 132 + } 99 133 throw e; 100 134 } 101 135 }); 102 136 } 103 137 138 + async updateCustomAction(opts: { 139 + orgId: string; 140 + actionId: string; 141 + patch: { 142 + name?: string; 143 + description?: string | null; 144 + callbackUrl?: string; 145 + callbackUrlHeaders?: JsonObject | null; 146 + callbackUrlBody?: JsonObject | null; 147 + applyUserStrikes?: boolean; 148 + }; 149 + itemTypeIds?: readonly string[] | undefined; 150 + }): Promise<CustomAction> { 151 + const { orgId, actionId, patch, itemTypeIds } = opts; 152 + return this.transactionWithRetry(async (trx) => { 153 + const existing = (await trx 154 + .selectFrom('public.actions') 155 + .select(actionDbSelection) 156 + .where('id', '=', actionId) 157 + .where('org_id', '=', orgId) 158 + .where('action_type', '=', 'CUSTOM_ACTION') 159 + .executeTakeFirst()) as ActionDbResult | undefined; 160 + 161 + if (existing == null) { 162 + throw makeNotFoundError('Action not found', { shouldErrorSpan: true }); 163 + } 164 + 165 + const setPayload = removeUndefinedKeys({ 166 + name: patch.name, 167 + description: patch.description, 168 + callback_url: patch.callbackUrl, 169 + callback_url_headers: patch.callbackUrlHeaders, 170 + callback_url_body: patch.callbackUrlBody, 171 + apply_user_strikes: patch.applyUserStrikes, 172 + }); 173 + const hasUserFields = Object.keys(setPayload).length > 0; 174 + const touchesJunction = itemTypeIds !== undefined; 175 + 176 + if (!hasUserFields && !touchesJunction) { 177 + const action = this.#dbResultToAction(existing); 178 + assertCustomAction(action); 179 + return action; 180 + } 181 + 182 + try { 183 + if (hasUserFields) { 184 + await trx 185 + .updateTable('public.actions') 186 + .set({ 187 + ...setPayload, 188 + updated_at: new Date(), 189 + }) 190 + .where('id', '=', actionId) 191 + .where('org_id', '=', orgId) 192 + .execute(); 193 + } 194 + 195 + if (itemTypeIds !== undefined) { 196 + await trx 197 + .deleteFrom('public.actions_and_item_types') 198 + .where('action_id', '=', actionId) 199 + .execute(); 200 + if (itemTypeIds.length > 0) { 201 + await trx 202 + .insertInto('public.actions_and_item_types') 203 + .values( 204 + itemTypeIds.map((item_type_id) => ({ 205 + action_id: actionId, 206 + item_type_id, 207 + })), 208 + ) 209 + .execute(); 210 + } 211 + } 212 + 213 + const refreshed = (await trx 214 + .selectFrom('public.actions') 215 + .select(actionDbSelection) 216 + .where('id', '=', actionId) 217 + .where('org_id', '=', orgId) 218 + .executeTakeFirstOrThrow()) as ActionDbResult; 219 + 220 + const action = this.#dbResultToAction(refreshed); 221 + assertCustomAction(action); 222 + return action; 223 + } catch (e: unknown) { 224 + if (isUniqueViolationError(e)) { 225 + throw makeActionNameExistsError({ shouldErrorSpan: true }); 226 + } 227 + throw e; 228 + } 229 + }); 230 + } 231 + 232 + async deleteCustomAction(opts: { orgId: string; actionId: string }) { 233 + const { orgId, actionId } = opts; 234 + return this.transactionWithRetry(async (trx) => { 235 + const row = await trx 236 + .selectFrom('public.actions') 237 + .select('id') 238 + .where('id', '=', actionId) 239 + .where('org_id', '=', orgId) 240 + .where('action_type', '=', 'CUSTOM_ACTION') 241 + .executeTakeFirst(); 242 + 243 + if (row == null) { 244 + return false; 245 + } 246 + 247 + await trx 248 + .deleteFrom('public.rules_and_actions') 249 + .where('action_id', '=', actionId) 250 + .execute(); 251 + await trx 252 + .deleteFrom('public.actions_and_item_types') 253 + .where('action_id', '=', actionId) 254 + .execute(); 255 + await trx 256 + .deleteFrom('public.actions') 257 + .where('id', '=', actionId) 258 + .where('org_id', '=', orgId) 259 + .execute(); 260 + 261 + return true; 262 + }); 263 + } 264 + 265 + async getActionsForItemType(opts: { 266 + orgId: string; 267 + itemTypeId: string; 268 + itemTypeKind: ItemTypeKind; 269 + readFromReplica?: boolean; 270 + }) { 271 + const { orgId, itemTypeId, itemTypeKind, readFromReplica } = opts; 272 + const pgQuery = this.#getPgQuery(readFromReplica); 273 + 274 + const [viaJunction, viaAppliesAll] = await Promise.all([ 275 + pgQuery 276 + .selectFrom('public.actions_and_item_types as ait') 277 + .innerJoin('public.actions as a', 'a.id', 'ait.action_id') 278 + .select(actionJoinDbSelection) 279 + .where('ait.item_type_id', '=', itemTypeId) 280 + .where('a.org_id', '=', orgId) 281 + .execute(), 282 + pgQuery 283 + .selectFrom('public.actions as a') 284 + .select(actionJoinDbSelection) 285 + .where('a.org_id', '=', orgId) 286 + .where( 287 + sql<boolean>`${itemTypeKind}::text = ANY(a.applies_to_all_items_of_kind::text[])`, 288 + ) 289 + .execute(), 290 + ]); 291 + 292 + const junctionRows = viaJunction as ActionDbResult[]; 293 + const appliesAllRows = viaAppliesAll as ActionDbResult[]; 294 + 295 + const byId = new Map<string, ActionDbResult>(); 296 + for (const row of [...junctionRows, ...appliesAllRows]) { 297 + byId.set(row.id, row); 298 + } 299 + return [...byId.values()].map((it) => this.#dbResultToAction(it)); 300 + } 301 + 104 302 async getActions(opts: { 105 303 orgId: string; 106 304 ids?: readonly string[]; ··· 120 318 } 121 319 122 320 async getActionsForRuleId(opts: { 321 + orgId: string; 123 322 ruleId: string; 124 323 readFromReplica?: boolean; 125 324 }) { 126 - const { ruleId, readFromReplica } = opts; 127 - const pgQuery = this.#getPgQuery(readFromReplica ?? true); 325 + const { orgId, ruleId, readFromReplica } = opts; 326 + const pgQuery = this.#getPgQuery(readFromReplica); 128 327 const results = (await pgQuery 129 328 .selectFrom('public.rules_and_actions as raa') 130 329 .innerJoin('public.actions as a', 'a.id', 'raa.action_id') 131 330 .select(actionJoinDbSelection) 132 331 .where('raa.rule_id', '=', ruleId) 332 + .where('a.org_id', '=', orgId) 133 333 .execute()) as ActionDbResult[]; 134 334 135 335 return results.map((it) => this.#dbResultToAction(it)); 136 336 } 137 337 338 + private static customMrtApiParamsFromDb( 339 + value: JsonValue[] | null, 340 + ): JsonValue | null { 341 + if (value == null || value.length === 0) { 342 + return null; 343 + } 344 + return value; 345 + } 346 + 138 347 #dbResultToAction(it: ActionDbResult) { 139 348 return { 140 349 id: it.id, 141 350 name: it.name, 351 + description: it.description, 142 352 orgId: it.orgId, 143 353 applyUserStrikes: it.applyUserStrikes, 144 354 penalty: it.penalty, ··· 150 360 callbackUrl: it.callbackUrl, 151 361 callbackUrlBody: it.callbackUrlBody, 152 362 callbackUrlHeaders: it.callbackUrlHeaders, 363 + customMrtApiParams: 364 + ActionOperations.customMrtApiParamsFromDb(it.customMrtApiParams), 153 365 }; 154 366 case 'ENQUEUE_TO_MRT': 155 367 case 'ENQUEUE_TO_NCMEC':
+8 -2
server/services/moderationConfigService/types/actions.ts
··· 2 2 // since this service should not have any dependencies on the model instances' 3 3 4 4 import { makeEnumLike } from '@roostorg/types'; 5 - import { type JsonObject, type ReadonlyDeep, type Simplify } from 'type-fest'; 5 + import { 6 + type JsonObject, 7 + type JsonValue, 8 + type ReadonlyDeep, 9 + type Simplify, 10 + } from 'type-fest'; 6 11 7 12 import { type TaggedUnionFromCases } from '../../../utils/typescript-types.js'; 8 13 ··· 29 34 id: string; 30 35 orgId: string; 31 36 name: string; 37 + description: string | null; 32 38 applyUserStrikes: boolean; 33 39 penalty: UserPenaltySeverity; 34 40 } & TaggedUnionFromCases< ··· 41 47 callbackUrl: string; 42 48 callbackUrlHeaders: JsonObject | null; 43 49 callbackUrlBody: JsonObject | null; 44 - customMrtApiParams: JsonObject | null; 50 + customMrtApiParams: JsonValue | null; 45 51 }; 46 52 } 47 53 >
+10 -1
server/services/signalsService/signals/aggregation/AggregationSignal.test.ts
··· 2 2 import { v1 as uuidv1 } from 'uuid'; 3 3 4 4 import { TestDateProvider } from '../../../../test/dateProvider.js'; 5 + import createActions from '../../../../test/fixtureHelpers/createActions.js'; 5 6 import createContentItemTypes from '../../../../test/fixtureHelpers/createContentItemTypes.js'; 6 7 import createOrg from '../../../../test/fixtureHelpers/createOrg.js'; 7 8 import createUser from '../../../../test/fixtureHelpers/createUser.js'; ··· 24 25 ModerationConfigService, 25 26 AggregationsService, 26 27 RuleAPIDataSource, 28 + ActionAPIDataSource, 27 29 ApiKeyService, 28 30 } = deps; 29 31 ··· 43 45 includeCreator: true, 44 46 extra: {}, 45 47 }); 48 + 49 + const { actions, cleanup: actionsCleanup } = await createActions({ 50 + actionAPI: ActionAPIDataSource, 51 + itemTypeIds: [itemTypes[0].id], 52 + orgId: org.id, 53 + }); 46 54 47 55 // Spy on aggregation service functions. 48 56 const aggregationsServiceSpy = AggregationsService; ··· 117 125 }, 118 126 ], 119 127 }, 120 - actionIds: ['73b2f15cc91'], 128 + actionIds: [actions[0].id], 121 129 policyIds: [], 122 130 tags: [], 123 131 maxDailyActions: null, ··· 135 143 dateProvider, 136 144 async cleanup() { 137 145 await RuleAPIDataSource.deleteRule({ id: rule.id, orgId: org.id }); 146 + await actionsCleanup(); 138 147 await itemTypesCleanup(); 139 148 await userCleanup(); 140 149 await orgCleanup();
+3
server/services/userStrikeService/userStrikeService.test.ts
··· 63 63 action: { 64 64 id: 'fakeActionId1', 65 65 name: 'testAction1', 66 + description: null, 66 67 applyUserStrikes: true, 67 68 orgId: 'fakeOrgId', 68 69 penalty: 'NONE' as const, ··· 105 106 action: { 106 107 id: 'fakeActionId1', 107 108 name: 'testAction1', 109 + description: null, 108 110 applyUserStrikes: false, 109 111 orgId: 'fakeOrgId', 110 112 penalty: 'NONE' as const, ··· 137 139 action: { 138 140 id: 'fakeActionId1', 139 141 name: 'testAction1', 142 + description: null, 140 143 applyUserStrikes: false, 141 144 orgId: 'fakeOrgId', 142 145 penalty: 'NONE' as const,
+1 -1
server/test/fixtureHelpers/createActions.ts
··· 27 27 actions, 28 28 async cleanup() { 29 29 await Promise.all( 30 - actions.map(async (it) => actionAPI.deleteAction(it.id, orgId)), 30 + actions.map(async (it) => actionAPI.deleteAction(orgId, it.id)), 31 31 ); 32 32 }, 33 33 };