import { type Opaque, type ReadonlyDeep } from 'type-fest'; import { jsonStringify } from '../../utils/encoding.js'; import { ErrorType, makeBadRequestError, type CoopError, } from '../../utils/errors.js'; import { type JSON } from '../../utils/json-schema-types.js'; import { instantiateOpaqueType, type NonEmptyArray, } from '../../utils/typescript-types.js'; import { type ItemType } from '../moderationConfigService/index.js'; import { getFieldValueForRole } from './extractItemDataValues.js'; import { fieldTypeHandlers } from './fieldTypeHandlers.js'; // The type of the content that we get from the client, pre validation. export type RawItemData = { readonly [key: string]: ReadonlyDeep }; export type NormalizedItemData = Opaque; /** * For some item field types, we allow input in many different formats (e.g., * we allow booleans to be represented by the strings '1' or '0' in input json). * However, our signals should always get each ScalarType with a consistent * runtime representation. So, this function 'normalizes' the representation of * an incoming item data object (which could also improve caching on the margin). * * @param itemType The item type that applies to the content. * @param data The submitted content. */ export function toNormalizedItemDataOrErrors( legalItemTypeIds: readonly string[], itemType: ItemType, data: RawItemData, ): NormalizedItemData | NonEmptyArray { type CoercionResult = ReturnType< (typeof fieldTypeHandlers)[keyof typeof fieldTypeHandlers]['coerce'] >; const fieldsByName = new Map( itemType.schema.map((field) => [field.name, field]), ); const normalizedEntriesWithErrors = Object.entries(data) .filter(([_, value]) => { // Remove all fields that were provided with a null value, as those // are always treated as being missing, equivalent to if the user had // omitted the key from the JSON payload. We have to do this _first_ so // that these nulls are never passed to `coerce`. return value != null; }) .map(([key, value]) => { const fieldDefinition = fieldsByName.get(key); return [ key, // If there's no field definition for this key in the schema, retain the // value as-is, which could come in useful later. Otherwise, normalize // the value according to its field type in the schema, possibly // producing errors. !fieldDefinition ? value : fieldDefinition.type === 'ARRAY' || fieldDefinition.type === 'MAP' ? fieldTypeHandlers[fieldDefinition.type].coerce( value, legalItemTypeIds, fieldDefinition.container as never, ) : fieldTypeHandlers[fieldDefinition.type].coerce( value, legalItemTypeIds, ), ] as const; }) .filter(([key, value]) => { // Now, remove all fields (that were defined in the schema) where the // value became `null` as a result of coercion/normalization, as these // should be treated like missing in the normalized result. return value != null || !fieldsByName.has(key); }); const potentialNormalizedResult = Object.fromEntries( normalizedEntriesWithErrors, ); // To find errors, we look over the fields _of the schema_, not the `data` // object, as any fields in the data object that aren't in the schema will // have been left as-is and can't have errors. const errors = itemType.schema.flatMap(({ name, required }) => { const normalizedValueOrError = potentialNormalizedResult[name]; // Either, the data didn't have a key for this field, or the field was // provided as null, or another value was provided, but that value was // equivalent to null after normalization; all these count as the field // being "missing", which is an error if the field is required. if (normalizedValueOrError == null && required) { return [ makeBadRequestError('Invalid Data for Item', { detail: `The field '${name}' is required, but was not provided.`, type: [ErrorType.DataInvalidForItemType], shouldErrorSpan: true, }), ]; } // If the validation/normalization process found an error, return that error. if (normalizedValueOrError instanceof Error) { const { message } = normalizedValueOrError; return [ makeBadRequestError('Invalid Data for Item', { detail: `The field '${name}' has an invalid value. The value you ` + `provided was: ${jsonStringify(data[name])}. ${message}`, type: [ErrorType.DataInvalidForItemType], shouldErrorSpan: true, }), ]; } return []; }); if (errors.length > 0) { return errors satisfies CoopError[] as NonEmptyArray; } const normalizedData = instantiateOpaqueType( // Cast is safe because of the check for errors above. // Arbitrary JSON values are possible from the fields we retained as-is, // that weren't in the schema. potentialNormalizedResult satisfies { [k: string]: CoercionResult | ReadonlyDeep; } as { [k: string]: Exclude, Error> }, ); if (itemType.kind === 'CONTENT') { const [parentId, threadId, createdAt] = ( ['parentId', 'threadId', 'createdAt'] as const ).map((role) => { return getFieldValueForRole( itemType.schema, itemType.schemaFieldRoles, role, normalizedData, ); }); if (parentId && (threadId === undefined || createdAt === undefined)) { return [ makeBadRequestError('Invalid field roles for Item', { detail: `You provided us a parent: ${itemType.schemaFieldRoles.parentId}` + ` without providing a value for when the item was created: ` + `${itemType.schemaFieldRoles.createdAt} or a value for the ` + `thread: ${itemType.schemaFieldRoles.threadId}`, type: [ErrorType.FieldRolesInvalidForItemType], shouldErrorSpan: true, }), ]; } if (threadId && createdAt === undefined) { return [ makeBadRequestError('Invalid field roles for Item', { detail: `You provided us a thread: ${itemType.schemaFieldRoles.threadId}` + ` without providing a value for when the item was created: ` + `${itemType.schemaFieldRoles.createdAt}`, type: [ErrorType.FieldRolesInvalidForItemType], shouldErrorSpan: true, }), ]; } } return normalizedData; }