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 557ff54b2b435e5f1e789c6a8a4e1bebf2d7deb6 165 lines 6.8 kB view raw
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}