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 307 lines 11 kB view raw
1import { 2 ContainerTypes, 3 makeDateString, 4 ScalarTypes, 5 type Container, 6 type ContainerType, 7 type ContainerTypeRuntimeType, 8 type ItemIdentifier, 9 type RelatedItem, 10 type ScalarType, 11 type ScalarTypeRuntimeType, 12} from '@roostorg/types'; 13import Geohash from 'latlon-geohash'; 14import _ from 'lodash'; 15import { match } from 'ts-pattern'; 16 17import { doesThrow } from '../../utils/misc.js'; 18import { isValidUrl, makeUrlString } from '../../utils/url.js'; 19 20const { isPlainObject } = _; 21 22/** 23 * For every FieldType, we define two core operations that can be performed on 24 * values (from JSON ItemData) that our schema says should be interpreted as 25 * that FieldType type. 26 * 27 * - `coerce`, on each FieldType, takes any value that shows up in the user 28 * input and returns either: 29 * 30 * - An error, if there's no way to treat the user input as a valid value 31 * for the given field type. 32 * 33 * - `null`, if the value should be treated as though the user didn't 34 * provide the field at all. E.g., on fields that take urls, we allow 35 * users to provide an empty string, and we treat the empty string 36 * equivalently to them having left the field out. (Note: if the user 37 * provides `null` directly, that's always treated equivalently to the 38 * field not being provided, so `coerce` needn't handle this case.) 39 * 40 * - the canonical value to use for this input, which satisifies the 41 * expected ScalarTypeRuntimeType/ContainerTypeRuntimeType for the field, 42 * if the value is valid or can be coerced to something valid. This makes 43 * sure that our signals always receive values of the type they expect. 44 * Eg, on a NUMBER Field, `coerce('13')` should return 13, the JS number. 45 * 46 * - `getValues`, on each FieldType, takes a value and returns any array of all 47 * the scalar values within value. For scalars, there's obviously just one 48 * value, which is the input value itself. But, for containers, this extracts 49 * all the values from the container. Note that the returned values must be 50 * valid ScalarTypeRuntimeType values. 51 */ 52type Handlers = { 53 [K in ScalarType]: { 54 coerce: ( 55 this: void, 56 value: unknown, 57 legalItemTypeIds: readonly string[], 58 ) => ScalarTypeRuntimeType<K> | null | Error; 59 getValues: (value: ScalarTypeRuntimeType<K>) => [ScalarTypeRuntimeType<K>]; 60 }; 61} & { 62 [K in ContainerType]: { 63 coerce: ( 64 this: void, 65 value: unknown, 66 legalItemTypeIds: readonly string[], 67 container: Container<K>, 68 ) => ContainerTypeRuntimeType<K> | null | Error; 69 getValues: ( 70 value: ContainerTypeRuntimeType<K>, 71 container: Container<K>, 72 ) => ScalarTypeRuntimeType[]; 73 }; 74}; 75 76/** 77 * The default implementation of `getValues` for ScalarTypes. By definition, 78 * a scalar type is an atomic value, so we just have to put it in an array. 79 */ 80const scalarGetValues = <T>(value: T): [T] => [value]; 81 82export const fieldTypeHandlers: Handlers = { 83// NB: for ids (including user ids), we accept numbers or strings for user 84// convenience, but we always coerce the value to a string so that we're not 85// mixing strings and numbers in the same json column in the data warehouse (which 86// could drastically reduce perf). 87 [ScalarTypes.USER_ID]: { 88 // TODO (COOP-745): USER_ID will be deprecated 89 coerce: (v, legalItemTypeIds) => { 90 // NB: We intentionally checks that the id is a string, rather than using 91 // `isIdLike` because we only allow users to give item ids as non-empty 92 // strings (despite our ID ScalarType allowing numbers and empty strings); 93 // cf RawItemSubmission['id']. 94 const isObjectWithRequisiteKeys = 95 typeof v === 'object' && 96 v !== null && 97 'id' in v && 98 typeof v.id === 'string' && 99 v['id'].length > 0 && 100 'typeId' in v && 101 legalItemTypeIds.includes(v['typeId'] as string); 102 103 return isObjectWithRequisiteKeys 104 ? (v satisfies { id: unknown; typeId: unknown } as ItemIdentifier) 105 : new Error( 106 "This field, if given, must be an object with a (non-empty) string 'id' and a valid 'typeId'.", 107 ); 108 }, 109 getValues: scalarGetValues, 110 }, 111 [ScalarTypes.ID]: { 112 coerce: coerceIdLikeInput, 113 getValues: scalarGetValues, 114 }, 115 [ScalarTypes.POLICY_ID]: { 116 coerce: coerceIdLikeInput, 117 getValues: scalarGetValues, 118 }, 119 [ScalarTypes.STRING]: { 120 coerce: (v) => 121 typeof v === 'string' 122 ? v 123 : new Error('This field, if given, must be a string.'), 124 getValues: scalarGetValues, 125 }, 126 [ScalarTypes.URL]: { 127 coerce: (v) => { 128 const urlString = typeof v === 'string' && makeUrlString(v); 129 130 return urlString 131 ? urlString 132 : v === '' 133 ? null 134 : new Error('This field, if given, must be a valid URL.'); 135 }, 136 getValues: scalarGetValues, 137 }, 138 [ScalarTypes.GEOHASH]: { 139 coerce: (v) => 140 typeof v === 'string' && !doesThrow(() => Geohash.decode(v)) 141 ? v 142 : v === '' 143 ? null 144 : new Error('This field, if given, must be a valid geohash.'), 145 getValues: scalarGetValues, 146 }, 147 [ScalarTypes.BOOLEAN]: { 148 coerce(v) { 149 if (typeof v === 'boolean') return v; 150 151 // For legacy reasons and/or user convenience, 152 // we accept boolean-like strings. 153 // TODO: create metric; see if this is used; if not, kill? 154 return match(String(v).toLowerCase()) 155 .with('true', '1', () => true) 156 .with('false', '0', () => false) 157 .otherwise( 158 () => new Error('This field, if given, must hold a boolean.'), 159 ); 160 }, 161 getValues: scalarGetValues, 162 }, 163 [ScalarTypes.NUMBER]: { 164 coerce(v) { 165 const asNumber = 166 typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : null; 167 return isFiniteNonNaNNumber(asNumber) 168 ? asNumber 169 : new Error('This field, if given, must hold a number.'); 170 }, 171 getValues: scalarGetValues, 172 }, 173 [ScalarTypes.AUDIO]: { 174 coerce: coerceMediaUrlInput, 175 getValues: scalarGetValues, 176 }, 177 [ScalarTypes.IMAGE]: { 178 coerce: coerceMediaUrlInput, 179 getValues: scalarGetValues, 180 }, 181 [ScalarTypes.VIDEO]: { 182 coerce: coerceMediaUrlInput, 183 getValues: scalarGetValues, 184 }, 185 [ScalarTypes.DATETIME]: { 186 getValues: scalarGetValues, 187 coerce(v) { 188 const asDateString = 189 typeof v === 'string' ? makeDateString(v) : undefined; 190 return asDateString 191 ? asDateString 192 : v === '' 193 ? null 194 : new Error('This field, if given, must contain a valid date string.'); 195 }, 196 }, 197 [ScalarTypes.RELATED_ITEM]: { 198 coerce: (v, legalItemTypeIds) => { 199 // NB: We intentionally checks that the id is a string, rather than using 200 // `isIdLike` because we only allow users to give item ids as non-empty 201 // strings (despite our ID ScalarType allowing numbers and empty strings); 202 // cf RawItemSubmission['id']. 203 const isObjectWithRequisiteKeys = 204 typeof v === 'object' && 205 v !== null && 206 'id' in v && 207 typeof v.id === 'string' && 208 v['id'].length > 0 && 209 'typeId' in v && 210 legalItemTypeIds.includes(v['typeId'] as string) && 211 (!('name' in v) || typeof v['name'] === 'string'); 212 213 return isObjectWithRequisiteKeys 214 ? (v satisfies { id: unknown; typeId: unknown } as RelatedItem) 215 : new Error( 216 "This field, if given, must be an object with a (non-empty) string 'id' and a valid 'typeId', with an optional string name.", 217 ); 218 }, 219 getValues: scalarGetValues, 220 }, 221 [ContainerTypes.ARRAY]: { 222 coerce(value, itemTypeIds, container) { 223 if (!Array.isArray(value)) { 224 return new Error('This field, if given, must be an array.'); 225 } 226 const coerceItem = fieldTypeHandlers[container.valueScalarType].coerce; 227 const normalizedValues = value.map((v) => coerceItem(v, itemTypeIds)); 228 return normalizedValues.some((v) => v instanceof Error) 229 ? new Error("Some items in this field's array were not valid.") 230 : (normalizedValues.filter((it) => it != null) as Exclude< 231 (typeof normalizedValues)[number], 232 Error | null 233 >[]); 234 }, 235 getValues: (v, _container) => v.slice(), 236 }, 237 [ContainerTypes.MAP]: { 238 coerce(v, itemTypeIds, container) { 239 if (!isPlainObject(v)) { 240 return new Error('This field, if given, must be an object.'); 241 } 242 const { keyScalarType, valueScalarType } = container; 243 const coerceKey = fieldTypeHandlers[keyScalarType].coerce; 244 const coerceValue = fieldTypeHandlers[valueScalarType].coerce; 245 246 const normalizedEntries = Object.entries(v as object) 247 .map( 248 ([key, val]) => 249 [ 250 coerceKey(key, itemTypeIds), 251 coerceValue(val, itemTypeIds), 252 ] as const, 253 ) 254 .filter(([key, val]) => key != null && val != null); 255 256 const hasErrors = normalizedEntries.some( 257 ([k, v]) => k instanceof Error || v instanceof Error, 258 ); 259 260 if (hasErrors) { 261 return new Error("Some entries in this field's map were not valid."); 262 } 263 264 return Object.fromEntries(normalizedEntries) as { 265 [k: string | number]: Exclude< 266 (typeof normalizedEntries)[number][1], 267 Error | null 268 >; 269 }; 270 }, 271 getValues: (v, _container) => Object.values(v), 272 }, 273}; 274 275function coerceMediaUrlInput(value: unknown) { 276 const err = new Error('This field, if given, must hold a valid URL.'); 277 278 return typeof value !== 'string' 279 ? err 280 : value === '' 281 ? null 282 : isValidUrl(value) 283 ? // NB: `value` here CANNOT be typed as a UrlString, because we have some 284 // legacy submissions in the data warehouse where the string is not a valid URL. 285 // (Usually, it's the empty string, which previously got through.) 286 // TODO: replace all those submissions in the data warehouse with `field: null`, 287 // and then update the type here/in ScalarTypeRuntimeType. 288 { url: value } 289 : err; 290} 291 292function coerceIdLikeInput(value: unknown) { 293 // NB: we don't currently have any restrictions on the string in an `ID` field; 294 // in particular, it's allowed to be empty. But note that _item ids_ (like in 295 // RELATED_ITEM fields) don't get checked w/ this function and can't be empty. 296 return typeof value === 'string' 297 ? value 298 : isFiniteNonNaNNumber(value) 299 ? String(value) 300 : new Error('This field must be a string or a number.'); 301} 302 303function isFiniteNonNaNNumber(value: unknown) { 304 return ( 305 typeof value === 'number' && Number.isFinite(value) && !Number.isNaN(value) 306 ); 307}