app/api/suggestions/updateEntities.ts
import entities from 'api/entities';
import { checkTypeIsAllowed } from 'api/services/informationextraction/ixextractors';
import thesauri from 'api/thesauri';
import { flatThesaurusValues } from 'api/thesauri/thesauri';
import { ObjectId } from 'mongodb';
import { arrayBidirectionalDiff } from 'shared/data_utils/arrayBidirectionalDiff';
import { IndexTypes, objectIndex } from 'shared/data_utils/objectIndex';
import { syncedPromiseLoop } from 'shared/data_utils/promiseUtils';
import { setIntersection } from 'shared/data_utils/setUtils';
import { ObjectIdSchema, PropertySchema } from 'shared/types/commonTypes';
import { EntitySchema } from 'shared/types/entityType';
import { IXSuggestionType } from 'shared/types/suggestionType';
class SuggestionAcceptanceError extends Error {}
interface AcceptedSuggestion {
_id: ObjectIdSchema;
sharedId: string;
entityId: string;
addedValues?: string[];
removedValues?: string[];
}
type EntityInfo = Record<string, { sharedId: string; template: ObjectId }>;
const fetchNoResources = async () => ({});
const fetchThesaurus = async (thesaurusId: PropertySchema['content']) => {
const dict = await thesauri.getById(thesaurusId);
const thesaurusName = dict!.name;
const flat = flatThesaurusValues(dict);
const indexedlabels = objectIndex(
flat,
v => v.id,
v => v.label
);
return { name: thesaurusName, id: thesaurusId, indexedlabels };
};
const fetchEntityInfo = async (
_property: PropertySchema,
acceptedSuggestions: AcceptedSuggestion[],
suggestions: IXSuggestionType[]
): Promise<{ entityInfo: EntityInfo }> => {
const suggestionSharedIds = suggestions.map(s => s.suggestedValue).flat();
const addedSharedIds = acceptedSuggestions.map(s => s.addedValues || []).flat();
const expectedSharedIds = Array.from(new Set(suggestionSharedIds.concat(addedSharedIds)));
const entitiesInDb = (await entities.get({ sharedId: { $in: expectedSharedIds } }, [
'sharedId',
'template',
])) as { sharedId: string; template: ObjectId }[];
const indexedBySharedId = objectIndex(
entitiesInDb,
e => e.sharedId,
e => e
);
return { entityInfo: indexedBySharedId };
};
const fetchSelectResources = async (property: PropertySchema) => {
const thesaurus = await fetchThesaurus(property.content);
return { thesaurus };
};
const resourceFetchers = {
title: fetchNoResources,
text: fetchNoResources,
numeric: fetchNoResources,
date: fetchNoResources,
select: fetchSelectResources,
multiselect: fetchSelectResources,
relationship: fetchEntityInfo,
};
const fetchResources = async (
property: PropertySchema,
acceptedSuggestions: AcceptedSuggestion[],
suggestions: IXSuggestionType[]
) => {
const type = checkTypeIsAllowed(property.type);
const fetcher = resourceFetchers[type];
return fetcher(property, acceptedSuggestions, suggestions);
};
const getAcceptedSuggestion = (
entity: EntitySchema,
acceptedSuggestionsBySharedId: Record<IndexTypes, AcceptedSuggestion>
): AcceptedSuggestion => acceptedSuggestionsBySharedId[entity.sharedId || ''];
const getSuggestion = (
entity: EntitySchema,
suggestionsById: Record<IndexTypes, IXSuggestionType>,
acceptedSuggestionsBySharedId: Record<IndexTypes, AcceptedSuggestion>
) => suggestionsById[getAcceptedSuggestion(entity, acceptedSuggestionsBySharedId)._id.toString()];
const getRawValue = (
entity: EntitySchema,
suggestionsById: Record<IndexTypes, IXSuggestionType>,
acceptedSuggestionsBySharedId: Record<IndexTypes, AcceptedSuggestion>
) => getSuggestion(entity, suggestionsById, acceptedSuggestionsBySharedId)?.suggestedValue;
const checkValuesInThesaurus = (
values: string[],
thesaurusName: string,
indexedlabels: Record<IndexTypes, string>
) => {
const missingValues = values.filter(v => !(v in indexedlabels));
if (missingValues.length === 1) {
throw new SuggestionAcceptanceError(`Id is invalid: ${missingValues[0]} (${thesaurusName}).`);
}
if (missingValues.length > 1) {
throw new SuggestionAcceptanceError(
`Ids are invalid: ${missingValues.join(', ')} (${thesaurusName}).`
);
}
};
function readAddedValues(acceptedSuggestion: AcceptedSuggestion, suggestionValues: string[]) {
const addedValues = acceptedSuggestion.addedValues || [];
const addedButNotSuggested = arrayBidirectionalDiff(
suggestionValues,
addedValues,
v => v,
v => v
).added;
if (addedButNotSuggested.length > 0) {
throw new SuggestionAcceptanceError(
`Some of the accepted values do not exist in the suggestion: ${addedButNotSuggested.join(', ')}. Cannot accept values that are not suggested.`
);
}
return addedValues;
}
function readRemovedValues(acceptedSuggestion: AcceptedSuggestion, suggestionValues: string[]) {
const removedValues = acceptedSuggestion.removedValues || [];
const removedButSuggested = setIntersection(removedValues, suggestionValues);
if (removedButSuggested.size > 0) {
throw new SuggestionAcceptanceError(
`Some of the removed values exist in the suggestion: ${Array.from(removedButSuggested).join(', ')}. Cannot remove values that are suggested.`
);
}
return removedValues;
}
function mixFinalValues(
entity: EntitySchema,
suggestion: IXSuggestionType,
addedValues: string[],
removedValues: string[]
) {
const removedValueSet = new Set(removedValues);
const entityValues = (entity.metadata?.[suggestion.propertyName] || []).map(
item => item.value
) as string[];
const newValues = arrayBidirectionalDiff(
entityValues,
addedValues,
v => v,
v => v
).added;
const finalValues = entityValues.filter(v => !removedValueSet.has(v)).concat(newValues);
return finalValues;
}
function arrangeAddedOrRemovedValues(
acceptedSuggestion: AcceptedSuggestion,
suggestionValues: string[],
entity: EntitySchema,
suggestion: IXSuggestionType
) {
let finalValues: string[] = [];
if (acceptedSuggestion.addedValues || acceptedSuggestion.removedValues) {
const addedValues = readAddedValues(acceptedSuggestion, suggestionValues);
const removedValues = readRemovedValues(acceptedSuggestion, suggestionValues);
finalValues = mixFinalValues(entity, suggestion, addedValues, removedValues);
} else {
finalValues = suggestionValues;
}
return finalValues;
}
function checkSharedIds(values: string[], entityInfo: EntityInfo) {
const missingSharedIds = values.filter(v => !(v in entityInfo));
if (missingSharedIds.length > 0) {
throw new SuggestionAcceptanceError(
`The following sharedIds do not exist in the database: ${missingSharedIds.join(', ')}.`
);
}
}
function checkTemplates(property: PropertySchema, values: string[], entityInfo: EntityInfo) {
const { content } = property;
if (!content) return;
const templateId = new ObjectId(content);
const wrongTemplatedSharedIds = values.filter(
v => entityInfo[v].template.toString() !== templateId.toString()
);
if (wrongTemplatedSharedIds.length > 0) {
throw new SuggestionAcceptanceError(
`The following sharedIds do not match the content template in the relationship property: ${wrongTemplatedSharedIds.join(', ')}.`
);
}
}
const getRawValueAsArray = (
_property: PropertySchema,
entity: EntitySchema,
suggestionsById: Record<IndexTypes, IXSuggestionType>,
acceptedSuggestionsBySharedId: Record<IndexTypes, AcceptedSuggestion>
) => [
{
value: getRawValue(entity, suggestionsById, acceptedSuggestionsBySharedId),
},
];
const valueGetters = {
text: getRawValueAsArray,
date: getRawValueAsArray,
numeric: getRawValueAsArray,
select: (
_property: PropertySchema,
entity: EntitySchema,
suggestionsById: Record<IndexTypes, IXSuggestionType>,
acceptedSuggestionsBySharedId: Record<IndexTypes, AcceptedSuggestion>,
resources: any
) => {
const { thesaurus } = resources;
const value = getRawValue(entity, suggestionsById, acceptedSuggestionsBySharedId) as string;
checkValuesInThesaurus([value], thesaurus.name, thesaurus.indexedlabels);
return [{ value }];
},
multiselect: (
_property: PropertySchema,
entity: EntitySchema,
suggestionsById: Record<IndexTypes, IXSuggestionType>,
acceptedSuggestionsBySharedId: Record<IndexTypes, AcceptedSuggestion>,
resources: any
) => {
const { thesaurus } = resources;
const acceptedSuggestion = getAcceptedSuggestion(entity, acceptedSuggestionsBySharedId);
const suggestion = getSuggestion(entity, suggestionsById, acceptedSuggestionsBySharedId);
const suggestionValues = getRawValue(
entity,
suggestionsById,
acceptedSuggestionsBySharedId
) as string[];
checkValuesInThesaurus(suggestionValues, thesaurus.name, thesaurus.indexedlabels);
const finalValues: string[] = arrangeAddedOrRemovedValues(
acceptedSuggestion,
suggestionValues,
entity,
suggestion
);
return finalValues.map(value => ({ value }));
},
relationship: (
property: PropertySchema,
entity: EntitySchema,
suggestionsById: Record<IndexTypes, IXSuggestionType>,
acceptedSuggestionsBySharedId: Record<IndexTypes, AcceptedSuggestion>,
resources: any
) => {
const { entityInfo } = resources;
const acceptedSuggestion = getAcceptedSuggestion(entity, acceptedSuggestionsBySharedId);
const suggestion = getSuggestion(entity, suggestionsById, acceptedSuggestionsBySharedId);
const suggestionValues = getRawValue(
entity,
suggestionsById,
acceptedSuggestionsBySharedId
) as string[];
checkSharedIds(suggestionValues, entityInfo);
checkTemplates(property, suggestionValues, entityInfo);
const finalValues: string[] = arrangeAddedOrRemovedValues(
acceptedSuggestion,
suggestionValues,
entity,
suggestion
);
return finalValues.map(value => ({ value }));
},
};
const getValue = (
property: PropertySchema,
entity: EntitySchema,
suggestionsById: Record<IndexTypes, IXSuggestionType>,
acceptedSuggestionsBySharedId: Record<IndexTypes, AcceptedSuggestion>,
resources: any
) => {
const type = checkTypeIsAllowed(property.type);
if (type === 'title') {
throw new SuggestionAcceptanceError('Title should not be handled here.');
}
const getter = valueGetters[type];
return getter(property, entity, suggestionsById, acceptedSuggestionsBySharedId, resources);
};
const saveEntities = async (entitiesToUpdate: EntitySchema[]) => {
await syncedPromiseLoop(entitiesToUpdate, async (entity: EntitySchema) => {
await entities.save(entity, { user: {}, language: entity.language });
});
};
const updateEntitiesWithSuggestion = async (
allLanguages: boolean,
acceptedSuggestions: AcceptedSuggestion[],
suggestions: IXSuggestionType[],
property: PropertySchema
) => {
const sharedIds = acceptedSuggestions.map(s => s.sharedId);
const entityIds = acceptedSuggestions.map(s => s.entityId);
const { propertyName } = suggestions[0];
const query = allLanguages
? { sharedId: { $in: sharedIds } }
: { sharedId: { $in: sharedIds }, _id: { $in: entityIds } };
const storedEntities = await entities.get(query, '+permissions');
const acceptedSuggestionsBySharedId = objectIndex(
acceptedSuggestions,
as => as.sharedId,
as => as
);
const suggestionsById = objectIndex(
suggestions,
s => s._id?.toString() || '',
s => s
);
const resources = await fetchResources(property, acceptedSuggestions, suggestions);
const entitiesToUpdate =
propertyName !== 'title'
? storedEntities.map((entity: EntitySchema) => ({
...entity,
metadata: {
...entity.metadata,
[propertyName]: getValue(
property,
entity,
suggestionsById,
acceptedSuggestionsBySharedId,
resources
),
},
permissions: entity.permissions || [],
}))
: storedEntities.map((entity: EntitySchema) => ({
...entity,
title: getRawValue(entity, suggestionsById, acceptedSuggestionsBySharedId),
}));
await saveEntities(entitiesToUpdate);
};
export { updateEntitiesWithSuggestion, SuggestionAcceptanceError };
export type { AcceptedSuggestion };