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 175 lines 6.7 kB view raw
1import { type Opaque, type ReadonlyDeep } from 'type-fest'; 2 3import { jsonStringify } from '../../utils/encoding.js'; 4import { 5 ErrorType, 6 makeBadRequestError, 7 type CoopError, 8} from '../../utils/errors.js'; 9import { type JSON } from '../../utils/json-schema-types.js'; 10import { 11 instantiateOpaqueType, 12 type NonEmptyArray, 13} from '../../utils/typescript-types.js'; 14import { type ItemType } from '../moderationConfigService/index.js'; 15import { getFieldValueForRole } from './extractItemDataValues.js'; 16import { fieldTypeHandlers } from './fieldTypeHandlers.js'; 17 18// The type of the content that we get from the client, pre validation. 19export type RawItemData = { readonly [key: string]: ReadonlyDeep<JSON> }; 20 21export type NormalizedItemData = Opaque<RawItemData, 'NormalizedItemData'>; 22 23/** 24 * For some item field types, we allow input in many different formats (e.g., 25 * we allow booleans to be represented by the strings '1' or '0' in input json). 26 * However, our signals should always get each ScalarType with a consistent 27 * runtime representation. So, this function 'normalizes' the representation of 28 * an incoming item data object (which could also improve caching on the margin). 29 * 30 * @param itemType The item type that applies to the content. 31 * @param data The submitted content. 32 */ 33export function toNormalizedItemDataOrErrors( 34 legalItemTypeIds: readonly string[], 35 itemType: ItemType, 36 data: RawItemData, 37): NormalizedItemData | NonEmptyArray<CoopError> { 38 type CoercionResult = ReturnType< 39 (typeof fieldTypeHandlers)[keyof typeof fieldTypeHandlers]['coerce'] 40 >; 41 42 const fieldsByName = new Map( 43 itemType.schema.map((field) => [field.name, field]), 44 ); 45 const normalizedEntriesWithErrors = Object.entries(data) 46 .filter(([_, value]) => { 47 // Remove all fields that were provided with a null value, as those 48 // are always treated as being missing, equivalent to if the user had 49 // omitted the key from the JSON payload. We have to do this _first_ so 50 // that these nulls are never passed to `coerce`. 51 return value != null; 52 }) 53 .map(([key, value]) => { 54 const fieldDefinition = fieldsByName.get(key); 55 return [ 56 key, 57 // If there's no field definition for this key in the schema, retain the 58 // value as-is, which could come in useful later. Otherwise, normalize 59 // the value according to its field type in the schema, possibly 60 // producing errors. 61 !fieldDefinition 62 ? value 63 : fieldDefinition.type === 'ARRAY' || fieldDefinition.type === 'MAP' 64 ? fieldTypeHandlers[fieldDefinition.type].coerce( 65 value, 66 legalItemTypeIds, 67 fieldDefinition.container as never, 68 ) 69 : fieldTypeHandlers[fieldDefinition.type].coerce( 70 value, 71 legalItemTypeIds, 72 ), 73 ] as const; 74 }) 75 .filter(([key, value]) => { 76 // Now, remove all fields (that were defined in the schema) where the 77 // value became `null` as a result of coercion/normalization, as these 78 // should be treated like missing in the normalized result. 79 return value != null || !fieldsByName.has(key); 80 }); 81 82 const potentialNormalizedResult = Object.fromEntries( 83 normalizedEntriesWithErrors, 84 ); 85 86 // To find errors, we look over the fields _of the schema_, not the `data` 87 // object, as any fields in the data object that aren't in the schema will 88 // have been left as-is and can't have errors. 89 const errors = itemType.schema.flatMap(({ name, required }) => { 90 const normalizedValueOrError = potentialNormalizedResult[name]; 91 92 // Either, the data didn't have a key for this field, or the field was 93 // provided as null, or another value was provided, but that value was 94 // equivalent to null after normalization; all these count as the field 95 // being "missing", which is an error if the field is required. 96 if (normalizedValueOrError == null && required) { 97 return [ 98 makeBadRequestError('Invalid Data for Item', { 99 detail: `The field '${name}' is required, but was not provided.`, 100 type: [ErrorType.DataInvalidForItemType], 101 shouldErrorSpan: true, 102 }), 103 ]; 104 } 105 106 // If the validation/normalization process found an error, return that error. 107 if (normalizedValueOrError instanceof Error) { 108 const { message } = normalizedValueOrError; 109 return [ 110 makeBadRequestError('Invalid Data for Item', { 111 detail: 112 `The field '${name}' has an invalid value. The value you ` + 113 `provided was: ${jsonStringify(data[name])}. ${message}`, 114 type: [ErrorType.DataInvalidForItemType], 115 shouldErrorSpan: true, 116 }), 117 ]; 118 } 119 120 return []; 121 }); 122 123 if (errors.length > 0) { 124 return errors satisfies CoopError[] as NonEmptyArray<CoopError>; 125 } 126 127 const normalizedData = instantiateOpaqueType<NormalizedItemData>( 128 // Cast is safe because of the check for errors above. 129 // Arbitrary JSON values are possible from the fields we retained as-is, 130 // that weren't in the schema. 131 potentialNormalizedResult satisfies { 132 [k: string]: CoercionResult | ReadonlyDeep<JSON>; 133 } as { [k: string]: Exclude<CoercionResult | ReadonlyDeep<JSON>, Error> }, 134 ); 135 136 if (itemType.kind === 'CONTENT') { 137 const [parentId, threadId, createdAt] = ( 138 ['parentId', 'threadId', 'createdAt'] as const 139 ).map((role) => { 140 return getFieldValueForRole( 141 itemType.schema, 142 itemType.schemaFieldRoles, 143 role, 144 normalizedData, 145 ); 146 }); 147 if (parentId && (threadId === undefined || createdAt === undefined)) { 148 return [ 149 makeBadRequestError('Invalid field roles for Item', { 150 detail: 151 `You provided us a parent: ${itemType.schemaFieldRoles.parentId}` + 152 ` without providing a value for when the item was created: ` + 153 `${itemType.schemaFieldRoles.createdAt} or a value for the ` + 154 `thread: ${itemType.schemaFieldRoles.threadId}`, 155 type: [ErrorType.FieldRolesInvalidForItemType], 156 shouldErrorSpan: true, 157 }), 158 ]; 159 } 160 if (threadId && createdAt === undefined) { 161 return [ 162 makeBadRequestError('Invalid field roles for Item', { 163 detail: 164 `You provided us a thread: ${itemType.schemaFieldRoles.threadId}` + 165 ` without providing a value for when the item was created: ` + 166 `${itemType.schemaFieldRoles.createdAt}`, 167 type: [ErrorType.FieldRolesInvalidForItemType], 168 shouldErrorSpan: true, 169 }), 170 ]; 171 } 172 } 173 174 return normalizedData; 175}