Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import _ from 'lodash';
2import { type ReadonlyDeep } from 'type-fest';
3
4import { type PolicyActionPenalties } from '../../models/OrgModel.js';
5import { jsonStringify, type JsonOf } from '../../utils/encoding.js';
6import { unzip2 } from '../../utils/fp-helpers.js';
7import { type UserActionStatistics } from './fetchUserActionStatistics.js';
8import { type UserSubmissionStatistics } from './fetchUserSubmissionStatistics.js';
9
10const { keyBy, sum, omit } = _;
11
12export type UserScore = 1 | 2 | 3 | 4 | 5;
13
14// This is the score we assign to a user before they've made their first
15// submission. It's seen by the content rules that run against a user's first
16// submission, and is used by user rules if we try to read a user's score in an
17// eventually-consistent way and don't yet see any recorded score for them.
18export const initialUserScore = 5;
19
20/**
21 * This is a pure function (i.e., it's given all the needed inputs and doesn't
22 * do any data fetching or writing) that computes a user's score.
23 */
24export function computeUserScore(
25 userSubmissionStats: Pick<
26 UserSubmissionStatistics,
27 'itemTypeId' | 'numSubmissions'
28 >[],
29 userActionStats: Pick<
30 UserActionStatistics,
31 'actionId' | 'policyId' | 'itemSubmissionIds' | 'count'
32 >[],
33 actionPenalties: readonly ReadonlyDeep<PolicyActionPenalties>[],
34) {
35 type PenaltyKeyData = [string, string | null];
36 const getPenaltyKey = (it: { actionId: string; policyId: string | null }) =>
37 jsonStringify([it.actionId, it.policyId] as PenaltyKeyData);
38
39 const penaltiesByActionAndPolicy = keyBy(actionPenalties, getPenaltyKey);
40
41 // The actions taken against a user fall into two buckets: those taken against
42 // their item submissions (by a rule or manually), and those taken against
43 // their user id itself (by a user rule or manually). We can identify the
44 // actions from user rules by comparing the `count` in a given (action,
45 // policy) statistics pair to the number of distinct item_submission_ids. We
46 // need to handle these seprately because, for actions triggered on item
47 // submissions, we only want to penalize each submitted item once, so we need
48 // to merge/dedupe actions to find the penalty of the "most severe" (action,
49 // policy) pair that that content item triggered.
50 const [userRuleActionStats, itemSubmissionActionStats] = unzip2(
51 userActionStats.map((it) => {
52 return [
53 {
54 ...omit(it, 'itemSubmissionIds'),
55 count: it.count - it.itemSubmissionIds.length,
56 },
57 omit(it, 'count'),
58 ] as const;
59 }),
60 );
61
62 // Before we compute the final penalties, figure out which (action, policy)'s
63 // penalty we're gonna use for each content submission. This is a little
64 // tricky, because each (action, policy) pair can have different penalties
65 // associated with it depending on how many "strikes" the user already has
66 // against that (action, policy) pair. That leads to complex interactions that
67 // we want to ignore; instead, to figure out the most severe, we just look at
68 // the first penalty for that pair.
69 const itemSubmissionIdsToPenaltyKeys = (() => {
70 const res: { [submissionId: string]: JsonOf<PenaltyKeyData>[] } = {};
71 for (const stats of itemSubmissionActionStats) {
72 const { itemSubmissionIds, actionId, policyId } = stats;
73
74 for (const submissionId of itemSubmissionIds) {
75 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
76 res[submissionId] = (res[submissionId] ?? []).concat(
77 getPenaltyKey({ actionId, policyId }),
78 );
79 }
80 }
81 return res;
82 })();
83
84 // NB: this will filter out some content submissions if none of that
85 // submission's (action, penalty) pairs have an associated penalty.
86 // Otherwise, it's gonna be an array with one penalty key per submission.
87 const itemSubmissionMostSeverePenaltyKeys = Object.entries(
88 itemSubmissionIdsToPenaltyKeys,
89 ).flatMap(([_, penaltyKeys]) => {
90 const maxPenalty = Math.max(
91 ...penaltyKeys.map(
92 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
93 (it) => penaltiesByActionAndPolicy[it]?.penalties[0] ?? 0,
94 ),
95 );
96
97 const maxPenaltyKey = penaltyKeys.find(
98 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
99 (it) => penaltiesByActionAndPolicy[it]?.penalties[0] === maxPenalty,
100 );
101 return maxPenaltyKey ? [maxPenaltyKey] : [];
102 });
103
104 const finalCountsByPenaltyKey = userRuleActionStats
105 .map((it) => [getPenaltyKey(it), it.count] as const)
106 .concat(itemSubmissionMostSeverePenaltyKeys.map((it) => [it, 1] as const))
107 .reduce(
108 (acc, [penaltyKey, count]) => {
109 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
110 acc[penaltyKey] = (acc[penaltyKey] ?? 0) + count;
111 return acc;
112 },
113 {} as { [penaltyKey: string]: number },
114 );
115
116 const penalties = Object.entries(finalCountsByPenaltyKey).flatMap(
117 ([key, count]) => {
118 const penaltiesForAction: readonly number[] =
119 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
120 penaltiesByActionAndPolicy[key]?.penalties ?? [];
121
122 // If the user has had an action taken against them more times than there
123 // are penalty values defined -- e.g., action X was taken 4 times, but the
124 // penalties are only defined for "strikes" 1-3 -- apply the last penalty
125 // value to all further actions.
126 const numDefinedPenalties = penaltiesForAction.length;
127 const numMissingPenalties = Math.max(0, count - numDefinedPenalties);
128
129 const extraPenalties = Array.from<number>({
130 length: numMissingPenalties,
131 }).fill(penaltiesForAction[penaltiesForAction.length - 1] ?? 0);
132
133 return penaltiesForAction.slice(0, count).concat(extraPenalties);
134 },
135 );
136
137 const numSubmissions = userSubmissionStats.reduce(
138 (acc, it) => acc + it.numSubmissions,
139 0,
140 );
141
142 if (numSubmissions === 0) {
143 return initialUserScore;
144 }
145
146 // The penalties are currently assumed to be 1, 3, 9, and 27 points, for
147 // small, medium, large, and extreme penalties, respectively. Meanwhile,
148 // each post will get one point. We'd like to not award points for posts
149 // that get penalized but, of course, we don't actually know which posts are
150 // which. So, instead, we deduct one extra point (to offset the point for
151 // the post) for each accrued penalty, even though this will double deduct
152 // if one post was penalized multiple times.
153 const weightedPenaltyRate = sum(penalties) / numSubmissions;
154 if (weightedPenaltyRate <= 0.01) {
155 return 5;
156 } else if (weightedPenaltyRate <= 0.05) {
157 return 4;
158 } else if (weightedPenaltyRate <= 0.1) {
159 return 3;
160 } else if (weightedPenaltyRate <= 0.25) {
161 return 2;
162 } else {
163 return 1;
164 }
165}