Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import { type Kysely, sql } from 'kysely';
2
3import {
4 type RuleAlarmStatus,
5 RuleStatus,
6 RuleType,
7 type ConditionSet,
8} from '../index.js';
9import { type ModerationConfigServicePg } from '../dbTypes.js';
10import { getUtcDateOnlyString } from '../../../utils/time.js';
11import {
12 type PlainRuleWithLatestVersion,
13 computeRuleStatusFromRow,
14} from '../../../models/rules/ruleTypes.js';
15
16const ruleSelect = [
17 'r.id',
18 'r.name',
19 'r.description',
20 'r.status_if_unexpired as statusIfUnexpired',
21 'r.tags',
22 'r.max_daily_actions as maxDailyActions',
23 'r.daily_actions_run as dailyActionsRun',
24 'r.last_action_date as lastActionDate',
25 'r.created_at as createdAt',
26 'r.updated_at as updatedAt',
27 'r.org_id as orgId',
28 'r.creator_id as creatorId',
29 'r.expiration_time as expirationTime',
30 'r.condition_set as conditionSet',
31 'r.alarm_status as alarmStatus',
32 'r.alarm_status_set_at as alarmStatusSetAt',
33 'r.rule_type as ruleType',
34 'r.parent_id as parentId',
35 'rlv.version as latestVersionString',
36] as const;
37
38type RuleRow = {
39 id: string;
40 name: string;
41 description: string | null;
42 statusIfUnexpired: Exclude<RuleStatus, typeof RuleStatus.EXPIRED>;
43 tags: string[];
44 maxDailyActions: number | null;
45 dailyActionsRun: number;
46 lastActionDate: string | null;
47 createdAt: Date;
48 updatedAt: Date;
49 orgId: string;
50 creatorId: string;
51 expirationTime: Date | null;
52 conditionSet: ConditionSet;
53 // Kysely returns the Postgres enum as a plain string; cast in rowToPlainRuleWithLatest.
54 alarmStatus: string;
55 alarmStatusSetAt: Date;
56 ruleType: RuleType;
57 parentId: string | null;
58 latestVersionString: string | null;
59};
60
61function enabledQuotaWhere(today: string) {
62 return sql<boolean>`(r.max_daily_actions is null or r.last_action_date is distinct from ${today}::date or (r.max_daily_actions is not null and r.daily_actions_run < r.max_daily_actions))`;
63}
64
65function rowToPlainRuleWithLatest(row: RuleRow): PlainRuleWithLatestVersion {
66 const status = computeRuleStatusFromRow(row.expirationTime, row.statusIfUnexpired);
67 const version = row.latestVersionString ?? '';
68 return {
69 id: row.id,
70 name: row.name,
71 description: row.description,
72 statusIfUnexpired: row.statusIfUnexpired,
73 status,
74 tags: row.tags,
75 maxDailyActions: row.maxDailyActions,
76 dailyActionsRun: row.dailyActionsRun,
77 lastActionDate: row.lastActionDate,
78 createdAt: row.createdAt,
79 updatedAt: row.updatedAt,
80 orgId: row.orgId,
81 creatorId: row.creatorId,
82 expirationTime: row.expirationTime,
83 conditionSet: row.conditionSet,
84 alarmStatus: row.alarmStatus as RuleAlarmStatus,
85 alarmStatusSetAt: row.alarmStatusSetAt,
86 ruleType: row.ruleType,
87 parentId: row.parentId,
88 latestVersion: { ruleId: row.id, version },
89 };
90}
91
92export default class RuleReadOperations {
93 constructor(
94 private readonly pgQuery: Kysely<ModerationConfigServicePg>,
95 private readonly pgQueryReplica: Kysely<ModerationConfigServicePg>,
96 ) {}
97
98 async getEnabledRulesForItemType(itemTypeId: string) {
99 const today = String(getUtcDateOnlyString());
100 const rows = (await this.pgQueryReplica
101 .selectFrom('public.rules as r')
102 .innerJoin('public.rules_and_item_types as rit', 'rit.rule_id', 'r.id')
103 .leftJoin('public.rules_latest_versions as rlv', 'rlv.rule_id', 'r.id')
104 .select(ruleSelect)
105 .where('rit.item_type_id', '=', itemTypeId)
106 .where((eb) =>
107 eb.or([
108 eb('r.expiration_time', 'is', null),
109 eb('r.expiration_time', '>', sql<Date>`now()`),
110 ]),
111 )
112 .where('r.status_if_unexpired', 'in', [
113 RuleStatus.LIVE,
114 RuleStatus.BACKGROUND,
115 ])
116 .where(enabledQuotaWhere(today))
117 .execute()) as RuleRow[];
118
119 return rows.map(rowToPlainRuleWithLatest);
120 }
121
122 /**
123 * Loads a single rule (with latest version row) scoped to an org.
124 * Used for GraphQL rule parents and permission checks without Sequelize.
125 *
126 * @param opts.readFromReplica — When false, reads from the primary (e.g. immediately after writes).
127 */
128 async getRuleByIdAndOrg(
129 ruleId: string,
130 orgId: string,
131 opts?: { readFromReplica?: boolean },
132 ): Promise<PlainRuleWithLatestVersion | null> {
133 const readFromReplica = opts?.readFromReplica ?? true;
134 const pg = readFromReplica ? this.pgQueryReplica : this.pgQuery;
135 const row = (await pg
136 .selectFrom('public.rules as r')
137 .leftJoin('public.rules_latest_versions as rlv', 'rlv.rule_id', 'r.id')
138 .select(ruleSelect)
139 .where('r.id', '=', ruleId)
140 .where('r.org_id', '=', orgId)
141 .executeTakeFirst()) as RuleRow | undefined;
142
143 if (row == null) {
144 return null;
145 }
146 return rowToPlainRuleWithLatest(row);
147 }
148
149 /**
150 * All rules for an org (latest version string), for GraphQL org.rules and
151 * similar list surfaces. Not filtered by enabled status.
152 */
153 async getRulesForOrg(
154 orgId: string,
155 opts?: { readFromReplica?: boolean },
156 ): Promise<PlainRuleWithLatestVersion[]> {
157 const readFromReplica = opts?.readFromReplica ?? true;
158 const pg = readFromReplica ? this.pgQueryReplica : this.pgQuery;
159 const rows = (await pg
160 .selectFrom('public.rules as r')
161 .leftJoin('public.rules_latest_versions as rlv', 'rlv.rule_id', 'r.id')
162 .select(ruleSelect)
163 .where('r.org_id', '=', orgId)
164 .orderBy('r.name', 'asc')
165 .execute()) as RuleRow[];
166
167 return rows.map(rowToPlainRuleWithLatest);
168 }
169
170 async findEnabledUserRules() {
171 const today = String(getUtcDateOnlyString());
172 const rows = (await this.pgQueryReplica
173 .selectFrom('public.rules as r')
174 .leftJoin('public.rules_latest_versions as rlv', 'rlv.rule_id', 'r.id')
175 .select(ruleSelect)
176 .where('r.rule_type', '=', RuleType.USER)
177 .where(
178 sql<boolean>`not exists (select 1 from public.rules_and_item_types rit where rit.rule_id = r.id)`,
179 )
180 .where((eb) =>
181 eb.or([
182 eb('r.expiration_time', 'is', null),
183 eb('r.expiration_time', '>', sql<Date>`now()`),
184 ]),
185 )
186 .where('r.status_if_unexpired', 'in', [
187 RuleStatus.LIVE,
188 RuleStatus.BACKGROUND,
189 ])
190 .where(enabledQuotaWhere(today))
191 .execute()) as RuleRow[];
192
193 return rows.map(rowToPlainRuleWithLatest);
194 }
195}