Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import { type ItemIdentifier } from '@roostorg/types';
2import _ from 'lodash';
3import { uid } from 'uid';
4
5import { type Dependencies } from '../../iocContainer/index.js';
6import {
7 getUserFromActionTargetItem,
8 type ActionExecutionData,
9 type ActionTargetItem,
10} from '../../rule_engine/ActionPublisher.js';
11import {
12 itemIdentifierToScyllaItemIdentifier,
13 scyllaItemIdentifierToItemIdentifier,
14 type Scylla,
15} from '../../scylla/index.js';
16import { type ActionExecutionCorrelationId } from '../analyticsLoggers/ActionExecutionLogger.js';
17import { filterNullOrUndefined } from '../../utils/collections.js';
18import { toCorrelationId } from '../../utils/correlationIds.js';
19import {
20 type Action,
21 type ModerationConfigService,
22} from '../moderationConfigService/index.js';
23import { type UserStrikesScyllaRelations } from './dbTypes.js';
24import { type IActionExecutionsAdapter } from '../../plugins/warehouse/queries/IActionExecutionsAdapter.js';
25
26const { maxBy } = _;
27
28export class UserStrikeService {
29 constructor(
30 private readonly scylla: Scylla<UserStrikesScyllaRelations>,
31 private readonly moderationConfigService: ModerationConfigService,
32 private readonly getUserStrikeTTLinDays: Dependencies['getUserStrikeTTLInDaysEventuallyConsistent'],
33 private readonly actionExecutionsAdapter: IActionExecutionsAdapter,
34 private readonly publishActions: Dependencies['ActionPublisher']['publishActions'],
35 ) {
36 this.scylla = scylla;
37 this.moderationConfigService = moderationConfigService;
38 this.publishActions = publishActions;
39 }
40
41 async getUserStrikes(orgId: string, userId: ItemIdentifier) {
42 return this.scylla.select({
43 from: 'user_strikes',
44 select: '*',
45 where: [
46 ['org_id', '=', orgId],
47 ['user_identifier', '=', itemIdentifierToScyllaItemIdentifier(userId)],
48 ],
49 });
50 }
51 async getUserStrikeValue(orgId: string, userId: ItemIdentifier) {
52 const strikes = await this.scylla.select({
53 from: 'user_strikes',
54 select: [
55 'org_id',
56 'user_identifier',
57 { aggregate: 'sum', col: 'user_strike_count' },
58 ],
59 where: [
60 ['org_id', '=', orgId],
61 ['user_identifier', '=', itemIdentifierToScyllaItemIdentifier(userId)],
62 ],
63 });
64 return strikes[0]?.user_strike_count ?? 0;
65 }
66 async getAllUserStrikeCountsForOrg(orgId: string) {
67 const strikes = await this.scylla.select({
68 from: 'user_strikes',
69 select: [
70 'user_identifier',
71 { aggregate: 'sum', col: 'user_strike_count' },
72 ],
73 where: [['org_id', '=', orgId]],
74 groupBy: ['org_id', 'user_identifier'],
75 });
76 return strikes.map((it) => ({
77 user_identifier: scyllaItemIdentifierToItemIdentifier(it.user_identifier),
78 strike_count: it.user_strike_count,
79 }));
80 }
81
82 async applyUserStrikeFromPublishedActions<
83 T extends ActionExecutionCorrelationId,
84 U extends ActionTargetItem,
85 >(
86 triggeredActions: Omit<
87 Omit<ActionExecutionData<T>, 'action'> & { action: Action },
88 'orgId' | 'correlationId' | 'targetItem'
89 >[],
90 executionContext: {
91 orgId: string;
92 correlationId: T;
93 targetItem: U;
94 sync?: boolean;
95 actorId?: string;
96 actorEmail?: string;
97 },
98 ) {
99 const targetUser = getUserFromActionTargetItem(executionContext.targetItem);
100 const mostSeverePolicy =
101 this.findMostSeverePolicyViolationFromActions(triggeredActions);
102
103 if (
104 mostSeverePolicy === undefined ||
105 mostSeverePolicy.userStrikeCount === 0 ||
106 targetUser == null
107 ) {
108 return;
109 }
110 // this variable is not really necessary, but helps
111 // TS keep track of the properly narrowed type of
112 // `mostSeverePolicy.userStrikeCount`, which can only be a number by this point
113 const currentStrikesToApply = mostSeverePolicy.userStrikeCount;
114
115 // we should get the user's current violation count
116 // before applying the strike to avoid double counting
117 const currentUserStrikes = await this.getUserStrikeValue(
118 executionContext.orgId,
119 targetUser,
120 );
121
122 await this.applyUserStrike(
123 executionContext.orgId,
124 targetUser,
125 mostSeverePolicy.id,
126 mostSeverePolicy.userStrikeCount,
127 );
128
129 // find threshold rules
130 const orgThresholds =
131 await this.moderationConfigService.getUserStrikeThresholdsForOrg(
132 executionContext.orgId,
133 );
134
135 if (orgThresholds.length === 0) {
136 return;
137 }
138 // To find which, if any, threshold rules to apply to this user, we
139 // 1. find all threshold rules that specify a threshold that is greater
140 // than the user's current strike count (meaning this current strike
141 // application could trigger the threshold rule)
142 // 2. find all the thresholds that have been crossed by applying the current
143 // strike to the user
144 // 3. find the greatest/most severe threshold rule that has been crossed.
145 // i.e. we will not apply multiple threshold rules if multiple are
146 // crossed at once, only the last/most severe one
147 const thresholdRuleToApply = orgThresholds
148 .filter((it) => it.threshold > currentUserStrikes)
149 .filter(
150 (it) => currentUserStrikes + currentStrikesToApply >= it.threshold,
151 )
152 // sort by threshold in descending order
153 .sort((a, b) => b.threshold - a.threshold)[0];
154
155 // construst the actions to publish
156 const actionsToPublish = await this.moderationConfigService.getActions({
157 orgId: executionContext.orgId,
158 ids: thresholdRuleToApply.actions,
159 readFromReplica: true,
160 });
161 const actionExecutionDataArray = actionsToPublish.map((action) => ({
162 action,
163 orgId: executionContext.orgId,
164 targetItem: executionContext.targetItem,
165 policies: [],
166 matchingRules: undefined,
167 ruleEnvironment: undefined,
168 }));
169 await this.publishActions(actionExecutionDataArray, {
170 orgId: executionContext.orgId,
171 correlationId: toCorrelationId({
172 type: 'user-strike-action-execution',
173 id: uid(),
174 }),
175 targetItem: executionContext.targetItem,
176 actorId: undefined,
177 actorEmail: undefined,
178 });
179 }
180
181 findMostSeverePolicyViolationFromActions<
182 T extends ActionExecutionCorrelationId,
183 >(
184 triggeredActions: Omit<
185 Omit<ActionExecutionData<T>, 'action'> & { action: Action },
186 'orgId' | 'correlationId' | 'targetItem'
187 >[],
188 ) {
189 const mostSeverePolicyPerAction = filterNullOrUndefined(
190 triggeredActions.map((action) => {
191 if (action.action.applyUserStrikes === false) {
192 return undefined;
193 }
194 return maxBy(action.policies, (policy) => policy.userStrikeCount);
195 }),
196 );
197 return maxBy(mostSeverePolicyPerAction, (policy) => policy.userStrikeCount);
198 }
199
200 async applyUserStrike(
201 orgId: string,
202 userId: ItemIdentifier,
203 policyId: string,
204 numStrikes: number,
205 ) {
206 const ttlInDays = await this.getUserStrikeTTLinDays(orgId);
207
208 await this.scylla.insert({
209 into: 'user_strikes',
210 row: {
211 user_identifier: itemIdentifierToScyllaItemIdentifier(userId),
212 created_at: new Date(),
213 policy_id: policyId,
214 org_id: orgId,
215 user_strike_count: numStrikes,
216 },
217 // The table has a default TTL of 90 Days, which we will usually override
218 // with the org's configured TTL. In the case we don't find one, we can
219 // just let the database use the TTL instead of specifying a default in the
220 // insert statement.
221 ttlInSeconds: ttlInDays ? ttlInDays * 24 * 60 * 60 : undefined,
222 });
223 }
224
225 async getRecentUserStrikeActions(opts: {
226 orgId: string;
227 filterBy?: {
228 startDate?: Date;
229 endDate?: Date;
230 };
231 limit?: number;
232 }) {
233 const { orgId, filterBy, limit } = opts;
234 const results = await this.actionExecutionsAdapter.getRecentUserStrikeActions({
235 orgId,
236 filterBy,
237 limit,
238 });
239 return filterNullOrUndefined(
240 results.map((it) =>
241 it.itemId && it.itemTypeId
242 ? {
243 actionId: it.actionId,
244 itemId: it.itemId,
245 itemTypeId: it.itemTypeId,
246 source: it.source,
247 time: it.occurredAt,
248 }
249 : undefined,
250 ),
251 );
252 }
253}