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 303 lines 11 kB view raw
1import _ from 'lodash'; 2import sequelize, { 3 Sequelize, 4 type CreationOptional, 5 type HasManyGetAssociationsMixin, 6 type HasManySetAssociationsMixin, 7 type HasOneGetAssociationMixin, 8 type InferAttributes, 9 type InferCreationAttributes, 10 type NonAttribute, 11} from 'sequelize'; 12 13import { 14 RuleAlarmStatus, 15 RuleStatus, 16 RuleType, 17 type ConditionSet, 18} from '../../services/moderationConfigService/index.js'; 19import { getUtcDateOnlyString } from '../../utils/time.js'; 20import { type DataTypes } from '../index.js'; 21import { type User } from '../UserModel.js'; 22import { type SequelizeAction } from './ActionModel.js'; 23import { type RuleLatestVersion } from './RuleLatestVersionModel.js'; 24 25const { Model, Op } = sequelize; 26const { without } = _; 27 28export type Rule = InstanceType<ReturnType<typeof makeRuleModel>>; 29export type RuleWithLatestVersion = Rule & 30 Required<Pick<Rule, 'latestVersion'>>; 31 32/** 33 * Data Model for Rules. Rules are comprised of 34 * ContentType inputs, Conditions, and Actions. 35 */ 36const makeRuleModel = (sequelize: Sequelize, DataTypes: DataTypes) => { 37 class Rule extends Model< 38 InferAttributes<Rule, { omit: 'createdAt' | 'updatedAt' }>, 39 InferCreationAttributes<Rule, { omit: 'createdAt' | 'updatedAt' }> 40 > { 41 public declare id: string; 42 public declare name: string; 43 public declare description?: string | null; 44 public declare expirationTime?: Date | null; 45 public declare statusIfUnexpired: CreationOptional< 46 Exclude<RuleStatus, typeof RuleStatus.EXPIRED> 47 >; 48 public declare lastActionDate: string | null; 49 public declare maxDailyActions: number | null; 50 public declare dailyActionsRun: CreationOptional<number>; 51 public declare status: RuleStatus; 52 53 public declare orgId: string; 54 public declare creatorId: string; 55 public declare tags: string[]; 56 public declare conditionSet: ConditionSet; 57 public declare ruleType: RuleType; 58 59 public declare alarmStatus: CreationOptional<RuleAlarmStatus>; 60 public declare alarmStatusSetAt: CreationOptional<Date>; 61 62 public declare getActions: HasManyGetAssociationsMixin<SequelizeAction>; 63 public declare setActions: HasManySetAssociationsMixin< 64 SequelizeAction, 65 string 66 >; 67 68 // Have to use any below to avoid circular type errors 69 /* eslint-disable @typescript-eslint/no-explicit-any */ 70 public declare getContentTypes: HasManyGetAssociationsMixin<any>; 71 public declare setContentTypes: HasManySetAssociationsMixin<any, string>; 72 73 public declare getPolicies: HasManyGetAssociationsMixin<any>; 74 public declare setPolicies: HasManySetAssociationsMixin<any, string>; 75 76 public declare getBacktests: HasManyGetAssociationsMixin<any>; 77 /* eslint-enable @typescript-eslint/no-explicit-any */ 78 79 public declare getCreator: HasOneGetAssociationMixin<User>; 80 81 public declare getLatestVersion: HasOneGetAssociationMixin<RuleLatestVersion>; 82 public declare latestVersion?: NonAttribute<RuleLatestVersion>; 83 84 public declare createdAt: Date; 85 public declare updatedAt: Date; 86 87 public declare parentId?: string | null; 88 89 // eslint-disable-next-line @typescript-eslint/no-explicit-any 90 static associate(models: { [key: string]: any }) { 91 Rule.belongsTo(models.Org, { as: 'Org', foreignKey: 'orgId' }); 92 Rule.belongsTo(models.User, { as: 'Creator', foreignKey: 'creatorId' }); 93 Rule.belongsToMany(models.ItemType, { 94 through: 'rules_and_item_types', 95 as: 'contentTypes', 96 otherKey: 'item_type_id', 97 }); 98 Rule.belongsToMany(models.Action, { 99 through: 'rules_and_actions', 100 as: 'Actions', 101 }); 102 Rule.belongsToMany(models.Policy, { 103 through: 'rules_and_policies', 104 as: 'Policies', 105 }); 106 Rule.hasMany(models.Backtest, { as: 'Backtests' }); 107 Rule.hasOne(models.RuleLatestVersion, { 108 as: 'latestVersion', 109 foreignKey: 'ruleId', 110 }); 111 } 112 113 /** 114 * Finds all enabled rules that are not associated with any content types. 115 * We call these "user rules" (or "pure user rules") because their input is 116 * solely data about a user -- rather than any content submission. 117 */ 118 static async findEnabledUserRules(): Promise<RuleWithLatestVersion[]> { 119 return Rule.sequelize!.transaction(async (t) => { 120 return Rule.scope('enabled').findAll({ 121 where: { ruleType: RuleType.USER }, 122 include: ['latestVersion'], 123 transaction: t, 124 }) as Promise<RuleWithLatestVersion[]>; 125 }); 126 } 127 } 128 129 /* Fields */ 130 Rule.init( 131 { 132 id: { 133 type: DataTypes.STRING, 134 primaryKey: true, 135 }, 136 orgId: { 137 allowNull: false, 138 type: DataTypes.STRING, 139 }, 140 creatorId: { 141 allowNull: false, 142 type: DataTypes.STRING, 143 }, 144 // Name of the Rule -- this must be unique for each Org (i.e. an Org can't 145 // have two Rules with the same name) 146 name: { 147 type: DataTypes.STRING, 148 allowNull: false, 149 validate: { notEmpty: true }, 150 }, 151 description: { 152 type: DataTypes.STRING, 153 allowNull: true, 154 }, 155 statusIfUnexpired: { 156 type: DataTypes.STRING, 157 allowNull: false, 158 defaultValue: RuleStatus.DRAFT, 159 validate: { 160 notEmpty: true, 161 isIn: [without(Object.values(RuleStatus), RuleStatus.EXPIRED)], 162 }, 163 }, 164 status: { 165 type: DataTypes.VIRTUAL(DataTypes.STRING, [ 166 'statusIfUnexpired', 167 'expirationTime', 168 ]), 169 get() { 170 const { expirationTime, statusIfUnexpired } = this; 171 return expirationTime && expirationTime.valueOf() < Date.now() 172 ? RuleStatus.EXPIRED 173 : statusIfUnexpired; 174 }, 175 set(value: RuleStatus) { 176 const expirationTime = this.expirationTime; 177 if (value === RuleStatus.EXPIRED) { 178 this.expirationTime = expirationTime 179 ? new Date(Math.max(expirationTime.valueOf(), Date.now())) 180 : new Date(); 181 } else { 182 this.statusIfUnexpired = value; 183 } 184 }, 185 }, 186 tags: { 187 type: DataTypes.ARRAY(DataTypes.STRING), 188 defaultValue: [], 189 allowNull: false, 190 }, 191 /** 192 * Maximum number of times this rule's actions can apply per day. 193 * Useful for slowly rolling out rules. The field name is a bit of a 194 * misnomer, in that it doesn't record the maximum _number of actions_ 195 * that a rule can trigger in a day, but rather the maximum _number of 196 * times_ all of the rule's actions can be triggered. 197 * 198 * NB: we use DataTypes.INTEGER (which is an int32), rather than BIGINT 199 * (which is an int64) so that the value can be represented as a JS Number, 200 * without us having to parse it to a bigint. int32 supports > 2 billion 201 * positive values, so this should be enough lol. 202 */ 203 maxDailyActions: { 204 type: DataTypes.INTEGER, 205 allowNull: true, 206 }, 207 /** 208 * The number of times this rule's actions were run in the most recent day 209 * when this rule's actions actually ran. That date is stored in 210 * lastActionDate. This field is used to enforce maxDailyActions. 211 */ 212 dailyActionsRun: { 213 type: DataTypes.INTEGER, 214 defaultValue: 0, 215 }, 216 /** 217 * The last date when this rule's actions were run. This is used 218 * to enforce maxDailyActions. 219 */ 220 lastActionDate: { 221 type: DataTypes.DATEONLY, 222 allowNull: true, 223 }, 224 expirationTime: { 225 type: DataTypes.DATE, 226 allowNull: true, 227 }, 228 conditionSet: { 229 type: DataTypes.JSONB, 230 allowNull: false, 231 }, 232 ruleType: { type: DataTypes.STRING, allowNull: false }, 233 alarmStatus: { 234 type: DataTypes.STRING, 235 validate: { 236 isIn: [Object.values(RuleAlarmStatus)], 237 }, 238 defaultValue: RuleAlarmStatus.INSUFFICIENT_DATA, 239 allowNull: false, 240 }, 241 alarmStatusSetAt: { 242 type: DataTypes.DATE, 243 defaultValue: new Date(), 244 allowNull: false, 245 }, 246 parentId: { 247 type: DataTypes.STRING, 248 allowNull: true, 249 }, 250 }, 251 { 252 sequelize, 253 modelName: 'rule', 254 underscored: true, 255 tableName: 'rules', 256 }, 257 ); 258 259 /** 260 * A scope for finding "enabled rules", where an an enabled rule is one that 261 * we'd run if a new piece of content (of one of the rule's content types) 262 * is submitted, or that we'd run against a user in the next user rule run. 263 * 264 * "Running the rule" just means checking if its conditions pass on the 265 * content; whether we'd run the actions of each passing rule is a different 266 * question. 267 * 268 * This function is _highly_ impure. Its results will change as rules 269 * expire, or as daily limits on rules are reached, among other things. 270 */ 271 Rule.addScope('enabled', () => ({ 272 // NB: this where query is brittle because it hardcodes the column name 273 // (max_daily_actions) for the maxDailyActions attribute of the Rule model. 274 // This hardcoding costs us type safety (once we set it up for sequelize) 275 // and automatic refactoring and makes it harder to find all uses of the 276 // attribute, so it's very bug-prone. But it seems to be the only thing 277 // that Sequelize supports for comparing two columns in a WHERE clause?!?! 278 // Meanwhile, we put `rule.max_daily_actions`, not just `max_daily_actions`, 279 // to make sure we get the right field, but this makes us reliant on even 280 // more details of the final query that we shouldn't have to know about. 281 where: { 282 // Keep rules that don't expire or haven't expired yet. 283 expirationTime: { [Op.or]: [null, { [Op.gt]: Sequelize.fn('now') }] }, 284 // And are in an enabled status. 285 statusIfUnexpired: { [Op.or]: [RuleStatus.LIVE, RuleStatus.BACKGROUND] }, 286 // And either don't have a daily actions quota, haven't run yet 287 // today (in which case they can't have exceeded the quota and the 288 // value in dailyActionsRun refers to a prior day), or have run 289 // today, but fewer times than their quota. 290 [Op.or]: [ 291 { maxDailyActions: null }, 292 { lastActionDate: { [Op.ne]: getUtcDateOnlyString() } }, 293 { 294 dailyActionsRun: { [Op.lt]: { [Op.col]: 'rule.max_daily_actions' } }, 295 }, 296 ], 297 }, 298 })); 299 300 return Rule; 301}; 302 303export default makeRuleModel;