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 9.1 kB view raw
1import { type Kysely } from 'kysely'; 2import { type Writable } from 'type-fest'; 3import { uid } from 'uid'; 4 5import { 6 UserPermission, 7 type Invoker, 8} from '../../../models/types/permissioning.js'; 9import { 10 CoopError, 11 ErrorType, 12 makeUnauthorizedError, 13 type ErrorInstanceData, 14} from '../../../utils/errors.js'; 15import { 16 isUniqueViolationError, 17 type FixKyselyRowCorrelation, 18 19} from '../../../utils/kysely.js'; 20import { removeUndefinedKeys } from '../../../utils/misc.js'; 21import { type ModerationConfigServicePg } from '../dbTypes.js'; 22import { type Policy } from '../index.js'; 23import type { PolicyType } from '../types/policies.js'; 24 25const policyDbSelection = [ 26 'id', 27 'name', 28 'org_id as orgId', 29 'parent_id as parentId', 30 'created_at as createdAt', 31 'updated_at as updatedAt', 32 'policy_text as policyText', 33 'enforcement_guidelines as enforcementGuidelines', 34 'sys_period as sysPeriod', 35 'policy_type as policyType', 36 'semantic_version as semanticVersion', 37 'user_strike_count as userStrikeCount', 38 'apply_user_strike_count_config_to_children as applyUserStrikeCountConfigToChildren', 39 'penalty', 40] as const; 41 42const policyJoinDbSelection = [ 43 'rap.rule_id as ruleId', 44 'p.id', 45 'p.name', 46 'p.org_id as orgId', 47 'p.parent_id as parentId', 48 'p.created_at as createdAt', 49 'p.updated_at as updatedAt', 50 'p.policy_text as policyText', 51 'p.enforcement_guidelines as enforcementGuidelines', 52 'p.sys_period as sysPeriod', 53 'p.policy_type as policyType', 54 'p.semantic_version as semanticVersion', 55 'p.user_strike_count as userStrikeCount', 56 'p.apply_user_strike_count_config_to_children as applyUserStrikeCountConfigToChildren', 57 'p.penalty', 58] as const; 59 60type PolicyDbResult = FixKyselyRowCorrelation< 61 ModerationConfigServicePg['public.policies'], 62 typeof policyDbSelection 63>; 64 65export default class PolicyOperations { 66 constructor( 67 private readonly pgQuery: Kysely<ModerationConfigServicePg>, 68 private readonly pgQueryReplica: Kysely<ModerationConfigServicePg>, 69 private readonly onDeletePolicyId: (opts: { 70 policyId: string; 71 orgId: string; 72 }) => Promise<void>, 73 ) {} 74 75 async getPolicies(opts: { orgId: string; readFromReplica?: boolean }) { 76 const { orgId, readFromReplica } = opts; 77 const pgQuery = this.#getPgQuery(readFromReplica); 78 const query = pgQuery 79 .selectFrom('public.policies') 80 .select(policyDbSelection) 81 .where('org_id', '=', orgId); 82 const results = (await query.execute()) as PolicyDbResult[]; 83 84 return results.map((it) => this.#dbResultToPolicy(it)); 85 } 86 87 async getPoliciesByIds(opts: { 88 orgId: string; 89 ids: readonly string[]; 90 readFromReplica?: boolean; 91 }): Promise<Policy[]> { 92 const { orgId, ids, readFromReplica } = opts; 93 if (ids.length === 0) { 94 return []; 95 } 96 const pgQuery = this.#getPgQuery(readFromReplica ?? true); 97 const results = (await pgQuery 98 .selectFrom('public.policies') 99 .select(policyDbSelection) 100 .where('org_id', '=', orgId) 101 .where('id', 'in', [...ids]) 102 .execute()) as PolicyDbResult[]; 103 104 return results.map((it) => this.#dbResultToPolicy(it)); 105 } 106 107 async getPoliciesByRuleIds(opts: { 108 ruleIds: readonly string[]; 109 readFromReplica?: boolean; 110 }): Promise<Record<string, Policy[]>> { 111 const { ruleIds, readFromReplica } = opts; 112 if (ruleIds.length === 0) { 113 return {}; 114 } 115 const pgQuery = this.#getPgQuery(readFromReplica ?? true); 116 type Row = PolicyDbResult & { ruleId: string }; 117 const rows = (await pgQuery 118 .selectFrom('public.rules_and_policies as rap') 119 .innerJoin('public.policies as p', 'p.id', 'rap.policy_id') 120 .select(policyJoinDbSelection) 121 .where('rap.rule_id', 'in', [...ruleIds]) 122 .execute()) as Row[]; 123 124 const out: Record<string, Policy[]> = {}; 125 for (const row of rows) { 126 const { ruleId, ...policyFields } = row; 127 const policy = this.#dbResultToPolicy(policyFields as PolicyDbResult); 128 (out[ruleId] ??= []).push(policy); 129 } 130 return out; 131 } 132 133 async getPolicy(opts: { 134 orgId: string; 135 policyId: string; 136 readFromReplica?: boolean; 137 }) { 138 const { orgId, policyId, readFromReplica } = opts; 139 const pgQuery = this.#getPgQuery(readFromReplica); 140 const query = pgQuery 141 .selectFrom('public.policies') 142 .select(policyDbSelection) 143 .where('org_id', '=', orgId) 144 .where('id', '=', policyId); 145 const result = 146 (await query.executeTakeFirst()) as PolicyDbResult; 147 148 return this.#dbResultToPolicy(result); 149 } 150 151 async createPolicy(opts: { 152 orgId: string; 153 policy: { 154 name: string; 155 parentId?: string | null; 156 policyText?: string | null; 157 enforcementGuidelines?: string | null; 158 policyType?: PolicyType | null; 159 }; 160 invokedBy: Invoker; 161 }) { 162 const { orgId: org_id, policy, invokedBy } = opts; 163 const { 164 name, 165 parentId: parent_id, 166 policyText: policy_text, 167 enforcementGuidelines: enforcement_guidelines, 168 policyType: policy_type, 169 } = policy; 170 if (!invokedBy.permissions.includes(UserPermission.MANAGE_POLICIES)) { 171 throw makeUnauthorizedError( 172 'You do not have permission to create policies', 173 { shouldErrorSpan: true }, 174 ); 175 } 176 177 try { 178 const newPolicy = await this.pgQuery 179 .insertInto('public.policies') 180 .values({ 181 id: uid(), 182 name, 183 org_id, 184 parent_id, 185 penalty: 'NONE', 186 policy_text, 187 enforcement_guidelines, 188 policy_type, 189 semantic_version: 1, 190 updated_at: new Date(), 191 }) 192 .returning(policyDbSelection) 193 .executeTakeFirstOrThrow(); 194 195 return this.#dbResultToPolicy(newPolicy); 196 } catch (e: unknown) { 197 throw isUniqueViolationError(e) 198 ? makePolicyNameExistsError({ shouldErrorSpan: true }) 199 : e; 200 } 201 } 202 203 async updatePolicy(opts: { 204 orgId: string; 205 policy: { 206 id: string; 207 name?: string; 208 parentId?: string | null; 209 policyText?: string | null; 210 enforcementGuidelines?: string | null; 211 policyType?: PolicyType | null; 212 userStrikeCount?: number | null; 213 applyUserStrikeCountConfigToChildren?: boolean | null; 214 }; 215 invokedBy: Invoker; 216 }) { 217 const { orgId, policy, invokedBy } = opts; 218 if (!invokedBy.permissions.includes(UserPermission.MANAGE_POLICIES)) { 219 throw makeUnauthorizedError( 220 'You do not have permission to update policies', 221 { shouldErrorSpan: true }, 222 ); 223 } 224 225 try { 226 const updatedPolicy = await this.pgQuery 227 .updateTable('public.policies') 228 .set( 229 removeUndefinedKeys({ 230 name: policy.name, 231 parent_id: policy.parentId, 232 policy_text: policy.policyText, 233 enforcement_guidelines: policy.enforcementGuidelines, 234 policy_type: policy.policyType, 235 user_strike_count: policy.userStrikeCount ?? undefined, 236 apply_user_strike_count_config_to_children: 237 policy.applyUserStrikeCountConfigToChildren ?? undefined, 238 updated_at: new Date(), 239 }), 240 ) 241 .where('org_id', '=', orgId) 242 .where('id', '=', policy.id) 243 .returning(policyDbSelection) 244 .executeTakeFirstOrThrow(); 245 246 return this.#dbResultToPolicy(updatedPolicy); 247 } catch (e: unknown) { 248 throw isUniqueViolationError(e) 249 ? makePolicyNameExistsError({ shouldErrorSpan: true }) 250 : e; 251 } 252 } 253 254 async deletePolicy(opts: { 255 orgId: string; 256 policyId: string; 257 invokedBy: Invoker; 258 }) { 259 const { orgId, policyId, invokedBy } = opts; 260 if (!invokedBy.permissions.includes(UserPermission.MANAGE_POLICIES)) { 261 throw makeUnauthorizedError( 262 'You do not have permission to delete policies', 263 { shouldErrorSpan: true }, 264 ); 265 } 266 267 const rowsDeleted = await this.pgQuery 268 .deleteFrom('public.policies') 269 .where('org_id', '=', orgId) 270 .where('id', '=', policyId) 271 .execute(); 272 273 if (rowsDeleted.length === 1) { 274 // We don't need to wait for this to complete before returning. 275 // Additionally, if it fails, we don't want to throw an error because 276 // it's not critical that it succeeds, it's just a 'best effort' cleanup. 277 this.onDeletePolicyId({ policyId, orgId }).catch(() => {}); 278 return true; 279 } 280 281 return false; 282 } 283 284 #dbResultToPolicy(it: PolicyDbResult) { 285 return it satisfies Writable<Policy> as Policy; 286 } 287 288 #getPgQuery(readFromReplica: boolean = false) { 289 return readFromReplica ? this.pgQueryReplica : this.pgQuery; 290 } 291} 292 293export type PolicyErrorType = 'PolicyNameExistsError'; 294 295// TODO: throw this error on failed policy creation/update when appropriate. 296export const makePolicyNameExistsError = (data: ErrorInstanceData) => 297 new CoopError({ 298 status: 409, 299 type: [ErrorType.UniqueViolation], 300 title: 'A policy with that name already exists in this organization.', 301 name: 'PolicyNameExistsError', 302 ...data, 303 });