Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
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;