diff --git a/.gitignore b/.gitignore index bb68e9a3..576fdaee 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist src/package.json .eslintcache .DS_Store +upload-docs* \ No newline at end of file diff --git a/docker-local-setup.sh b/docker-local-setup.sh index ca5bba0c..5fffceca 100755 --- a/docker-local-setup.sh +++ b/docker-local-setup.sh @@ -22,6 +22,6 @@ to build missing images";echo; fi echo;echo "Starting Docker Compose...";echo -CHT_USER_MANAGEMENT_IMAGE=cht-user-management:local CHT_USER_MANAGEMENT_WORKER_IMAGE=cht-user-management-worker:local docker compose up -d +CHT_USER_MANAGEMENT_IMAGE=cht-user-management:local CHT_USER_MANAGEMENT_WORKER_IMAGE=cht-user-management-worker:local docker compose up echo;echo "Server is now running at http://127.0.0.1:$EXTERNAL_PORT/login";echo diff --git a/package-lock.json b/package-lock.json index 7ff335fb..f4ab4563 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cht-user-management", - "version": "1.4.2", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cht-user-management", - "version": "1.4.2", + "version": "1.5.0", "license": "ISC", "dependencies": { "@bull-board/api": "^5.17.0", diff --git a/package.json b/package.json index 413728f8..d5fdb44d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cht-user-management", - "version": "1.4.2", + "version": "1.5.0", "main": "dist/index.js", "dependencies": { "@bull-board/api": "^5.17.0", diff --git a/scripts/create-user-managers/create-user-managers.ts b/scripts/create-user-managers/create-user-managers.ts index e9231d74..43f1c6ae 100644 --- a/scripts/create-user-managers/create-user-managers.ts +++ b/scripts/create-user-managers/create-user-managers.ts @@ -3,8 +3,10 @@ import { Command } from 'commander'; import { AuthenticationInfo, ContactType } from '../../src/config'; import { createUserWithRetries } from '../../src/lib/retry-logic'; import Place from '../../src/services/place'; -import { UserPayload } from '../../src/services/user-payload'; +import RemotePlaceCache, { RemotePlace } from '../../src/lib/remote-place-cache'; +import { PropertyValues, UnvalidatedPropertyValue } from '../../src/property-value'; import UserManager from './ke_user_manager.json'; +import { UserPayload } from '../../src/services/user-payload'; const { ChtApi } = require('../../src/lib/cht-api'); // require is needed for rewire const ChtSession = require('../../src/lib/cht-session').default; // require is needed for rewire @@ -53,8 +55,8 @@ export default async function createUserManagers(argv: string[]) { async function createUserManager(username: string, placeDocId: string, chtApi: typeof ChtApi, adminUsername: string, passwordOverride?: string) { const place = new Place(UserManagerContactType); - place.contact.properties.name = `${username} (User Manager)`; - place.userRoleProperties.role = UserManagerContactType.user_role.join(' '); + place.contact.properties.name = new UnvalidatedPropertyValue(`${username} (User Manager)`, 'name'); + place.userRoleProperties.role = new UnvalidatedPropertyValue(UserManagerContactType.user_role.join(' '), 'role'); const chtPayload = place.asChtPayload(adminUsername); chtPayload.contact.role = 'user_manager'; @@ -96,8 +98,8 @@ function parseCommandlineArguments(argv: string[]): CommandLineArgs { } async function getPlaceDocId(county: string | undefined, chtApi: typeof ChtApi) { - const counties = await chtApi.getPlacesWithType('a_county'); - const countyMatches = counties.filter((c: any) => !county || c.name === county.toLowerCase()); + const counties = await RemotePlaceCache.getPlacesWithType(chtApi, UserManagerContactType, UserManagerContactType.hierarchy[0]); + const countyMatches = counties.filter((c: RemotePlace) => !county || PropertyValues.isMatch(county, c.name)); if (countyMatches.length < 1) { throw Error(`Could not find county "${county}"`); } diff --git a/scripts/create-user-managers/ke_user_manager.json b/scripts/create-user-managers/ke_user_manager.json index 3fe7cf67..4c1b3615 100644 --- a/scripts/create-user-managers/ke_user_manager.json +++ b/scripts/create-user-managers/ke_user_manager.json @@ -4,7 +4,15 @@ "contact_type": "person", "user_role": ["user_manager", "mm-online"], "username_from_place": false, - "hierarchy": [], + "hierarchy": [{ + "type": "name", + "friendly_name": "County", + "property_name": "name", + "required": false, + "parameter": ["\\sCounty"], + "contact_type": "a_county", + "level": 0 + }], "deactivate_users_on_replace": false, "replacement_property": { "friendly_name": "Unused", diff --git a/src/config/config-factory.ts b/src/config/config-factory.ts index dc6a7169..243b865b 100644 --- a/src/config/config-factory.ts +++ b/src/config/config-factory.ts @@ -4,7 +4,7 @@ import kenyaConfig from './chis-ke'; import togoConfig from './chis-tg'; import civConfig from './chis-civ'; -const CONFIG_MAP: { [key: string]: PartnerConfig } = { +export const CONFIG_MAP: { [key: string]: PartnerConfig } = { 'CHIS-KE': kenyaConfig, 'CHIS-UG': ugandaConfig, 'CHIS-TG': togoConfig, diff --git a/src/config/config-worker.ts b/src/config/config-worker.ts index 693451e0..ae6ee3f9 100644 --- a/src/config/config-worker.ts +++ b/src/config/config-worker.ts @@ -12,6 +12,12 @@ export const WorkerConfig = { port: Number(environment.REDIS_PORT), }, moveContactQueue: 'MOVE_CONTACT_QUEUE', + defaultJobOptions: { + attempts: 3, // Max retries for a failed job + backoff: { + type: 'custom', + }, + } }; const assertRedisConfig = () => { diff --git a/src/config/index.ts b/src/config/index.ts index 69b32141..70f4df25 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,6 +1,7 @@ import _ from 'lodash'; import { ChtApi, PlacePayload } from '../lib/cht-api'; import getConfigByKey from './config-factory'; +import Validation from '../validation'; export type ConfigSystem = { domains: AuthenticationInfo[]; @@ -28,10 +29,13 @@ export type ContactType = { hint?: string; }; +const KnownContactPropertyTypes = [...Validation.getKnownContactPropertyTypes()] as const; +export type ContactPropertyType = typeof KnownContactPropertyTypes[number]; + export type HierarchyConstraint = { friendly_name: string; property_name: string; - type: string; + type: ContactPropertyType; required: boolean; parameter? : string | string[] | object; errorDescription? : string; @@ -43,7 +47,7 @@ export type HierarchyConstraint = { export type ContactProperty = { friendly_name: string; property_name: string; - type: string; + type: ContactPropertyType; required: boolean; parameter? : string | string[] | object; errorDescription? : string; @@ -55,6 +59,7 @@ export type AuthenticationInfo = { useHttp?: boolean; }; + const { CONFIG_NAME, NODE_ENV, @@ -187,6 +192,33 @@ export class Config { return _.sortBy(domains, 'friendly'); } + // TODO: Joi? Chai? + public static assertValid({ config }: PartnerConfig = partnerConfig) { + for (const contactType of config.contact_types) { + const allHierarchyProperties = [...contactType.hierarchy, contactType.replacement_property]; + const allProperties = [ + ...contactType.place_properties, + ...contactType.contact_properties, + ...allHierarchyProperties, + Config.getUserRoleConfig(contactType), + ]; + + Config.getPropertyWithName(contactType.place_properties, 'name'); + Config.getPropertyWithName(contactType.contact_properties, 'name'); + + allProperties.forEach(property => { + if (!KnownContactPropertyTypes.includes(property.type)) { + throw Error(`Unknown property type "${property.type}"`); + } + }); + + const generatedHierarchyProperties = allHierarchyProperties.filter(hierarchy => hierarchy.type === 'generated'); + if (generatedHierarchyProperties.length) { + throw Error('Hierarchy properties cannot be of type "generated"'); + } + } + } + public static getCsvTemplateColumns(placeType: string) { const placeTypeConfig = Config.getContactType(placeType); const hierarchy = Config.getHierarchyWithReplacement(placeTypeConfig); @@ -205,3 +237,4 @@ export class Config { return columns; } } + diff --git a/src/index.ts b/src/index.ts index 95a599b7..e78be9f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ require('dotenv').config(); +import { Config } from './config'; import build from './server'; import { env } from 'process'; const { @@ -6,15 +7,11 @@ const { } = process.env; const port: number = env.PORT ? parseInt(env.PORT) : 3500; +Config.assertValid(); (async () => { - const loggerConfig = { - transport: { - target: 'pino-pretty', - }, - }; const server = build({ - logger: loggerConfig, + logger: true, }); // in 1.1.0 we allowed INTERFACE to be declared in .env, but let's be diff --git a/src/lib/authentication.ts b/src/lib/authentication.ts index 720df9d7..9605cc5f 100644 --- a/src/lib/authentication.ts +++ b/src/lib/authentication.ts @@ -2,8 +2,8 @@ import process from 'process'; import jwt from 'jsonwebtoken'; import ChtSession from './cht-session'; -const LOGIN_EXPIRES_AFTER_MS = 2 * 24 * 60 * 60 * 1000; -const QUEUE_SESSION_EXPIRATION = '48h'; +const LOGIN_EXPIRES_AFTER_MS = 4 * 24 * 60 * 60 * 1000; +const QUEUE_SESSION_EXPIRATION = '96h'; const { COOKIE_PRIVATE_KEY, WORKER_PRIVATE_KEY } = process.env; const PRIVATE_KEY_SALT = '_'; // change to logout all users const COOKIE_SIGNING_KEY = COOKIE_PRIVATE_KEY + PRIVATE_KEY_SALT; diff --git a/src/lib/cht-api.ts b/src/lib/cht-api.ts index eb183b8f..eabafc64 100644 --- a/src/lib/cht-api.ts +++ b/src/lib/cht-api.ts @@ -1,8 +1,8 @@ import _ from 'lodash'; +import { AxiosInstance } from 'axios'; import ChtSession from './cht-session'; import { Config, ContactType } from '../config'; import { UserPayload } from '../services/user-payload'; -import { AxiosInstance } from 'axios'; export type PlacePayload = { name: string; @@ -18,17 +18,6 @@ export type PlacePayload = { [key: string]: any; }; -export type RemotePlace = { - id: string; - name: string; - lineage: string[]; - ambiguities?: RemotePlace[]; - - // sadly, sometimes invalid or uncreated objects "pretend" to be remote - // should reconsider this naming - type: 'remote' | 'local' | 'invalid'; -}; - export class ChtApi { public readonly chtSession: ChtSession; private axiosInstance: AxiosInstance; @@ -174,26 +163,15 @@ export class ChtApi { }; getPlacesWithType = async (placeType: string) - : Promise => { - const url = `medic/_design/medic-client/_view/contacts_by_type_freetext`; + : Promise => { + const url = `medic/_design/medic-client/_view/contacts_by_type`; const params = { - startkey: JSON.stringify([ placeType, 'name:']), - endkey: JSON.stringify([ placeType, 'name:\ufff0']), + key: JSON.stringify([placeType]), include_docs: true, }; console.log('axios.get', url, params); const resp = await this.axiosInstance.get(url, { params }); - - return resp.data.rows - .map((row: any): RemotePlace => { - const nameData = row.key[1]; - return { - id: row.id, - name: nameData.substring('name:'.length), - lineage: extractLineage(row.doc), - type: 'remote', - }; - }); + return resp.data.rows.map((row: any) => row.doc); }; getDoc = async (id: string): Promise => { @@ -228,10 +206,3 @@ function minify(doc: any): any { }; } -function extractLineage(doc: any): string[] { - if (doc?.parent?._id) { - return [doc.parent._id, ...extractLineage(doc.parent)]; - } - - return []; -} diff --git a/src/lib/cht-session.ts b/src/lib/cht-session.ts index 586ff7f1..0c604792 100644 --- a/src/lib/cht-session.ts +++ b/src/lib/cht-session.ts @@ -5,7 +5,7 @@ import { AuthenticationInfo } from '../config'; import { AxiosHeaders, AxiosInstance } from 'axios'; import axiosRetry from 'axios-retry'; import { axiosRetryConfig } from './retry-logic'; -import { RemotePlace } from './cht-api'; +import { RemotePlace } from './remote-place-cache'; const COUCH_AUTH_COOKIE_NAME = 'AuthSession='; const ADMIN_FACILITY_ID = '*'; diff --git a/src/lib/credentials-file.ts b/src/lib/credentials-file.ts new file mode 100644 index 00000000..815282bd --- /dev/null +++ b/src/lib/credentials-file.ts @@ -0,0 +1,52 @@ +import { Config, ContactType } from '../config'; +import SessionCache from '../services/session-cache'; +import { stringify } from 'csv/sync'; + +type File = { + filename: string; + content: string; +}; + +export default function getCredentialsFiles(sessionCache: SessionCache, contactTypes: ContactType[]): File[] { + const files: File[] = []; + for (const contactType of contactTypes) { + const places = sessionCache.getPlaces({ type: contactType.name }); + if (!places.length) { + continue; + } + + const rows = places.map((place) => [ + ...Object.values(place.hierarchyProperties).map(prop => prop.formatted), + place.name, + place.contact.properties.name?.formatted, + place.contact.properties.phone?.formatted, + place.userRoles.join(' '), + place.creationDetails.username, + place.creationDetails.password, + ]); + + const constraints = Config.getHierarchyWithReplacement(contactType); + const props = Object.keys(places[0].hierarchyProperties) + .map(prop => constraints.find(c => c.property_name === prop)!.friendly_name); + const columns = [ + ...props, + contactType.friendly, + 'name', + 'phone', + 'role', + 'username', + 'password', + ]; + + const content = stringify(rows, { + columns, + header: true, + }); + files.push({ + filename: `${contactType.name}.csv`, + content, + }); + } + + return files; +} diff --git a/src/lib/move.ts b/src/lib/move.ts index 4613e457..3e19db84 100644 --- a/src/lib/move.ts +++ b/src/lib/move.ts @@ -24,7 +24,7 @@ export default class MoveLib { } if (toId === fromLineage[1]?.id) { - throw Error(`Place "${fromLineage[0]?.name}" already has "${toLineage[1]?.name}" as parent`); + throw Error(`Place "${fromLineage[0]?.name.original}" already has "${toLineage[1]?.name.original}" as parent`); } const jobName = this.getJobName(fromLineage, toLineage); @@ -63,7 +63,7 @@ async function resolve(prefix: string, formData: any, contactType: ContactType, await RemotePlaceResolver.resolveOne(place, sessionCache, chtApi, { fuzz: true }); place.validate(); - const validationError = place.validationErrors && Object.keys(place.validationErrors).find(err => err.startsWith('hierarchy_')); + const validationError = place.validationErrors && Object.keys(place.validationErrors).find(err => err.startsWith(prefix)); if (validationError) { throw Error(place.validationErrors?.[validationError]); } diff --git a/src/lib/queues.ts b/src/lib/queues.ts index 2b68de2a..398495d4 100644 --- a/src/lib/queues.ts +++ b/src/lib/queues.ts @@ -1,5 +1,5 @@ import { v4 } from 'uuid'; -import { JobsOptions, Queue, ConnectionOptions } from 'bullmq'; +import { JobsOptions, Queue, ConnectionOptions, DefaultJobOptions } from 'bullmq'; import { WorkerConfig } from '../config/config-worker'; export interface IQueue { @@ -17,9 +17,9 @@ export class BullQueue implements IQueue { public readonly name: string; public readonly bullQueue: Queue; - constructor(queueName: string, connection: ConnectionOptions) { + constructor(queueName: string, connection: ConnectionOptions, defaultJobOptions?: DefaultJobOptions) { this.name = queueName; - this.bullQueue = new Queue(queueName, { connection }); + this.bullQueue = new Queue(queueName, { connection, defaultJobOptions }); } public async add(jobParams: JobParams): Promise { @@ -37,5 +37,6 @@ export class BullQueue implements IQueue { export const getMoveContactQueue = () => new BullQueue( WorkerConfig.moveContactQueue, - WorkerConfig.redisConnection + WorkerConfig.redisConnection, + WorkerConfig.defaultJobOptions ); diff --git a/src/lib/remote-place-cache.ts b/src/lib/remote-place-cache.ts index deb11f35..3bffc991 100644 --- a/src/lib/remote-place-cache.ts +++ b/src/lib/remote-place-cache.ts @@ -1,5 +1,8 @@ import Place from '../services/place'; -import { ChtApi, RemotePlace } from './cht-api'; +import { ChtApi } from './cht-api'; +import { IPropertyValue } from '../property-value'; +import { ContactType, HierarchyConstraint } from '../config'; +import { NamePropertyValue } from '../property-value/name-property-value'; type RemotePlacesByType = { [key: string]: RemotePlace[]; @@ -9,18 +12,36 @@ type RemotePlaceDatastore = { [key: string]: RemotePlacesByType; }; +export type RemotePlace = { + id: string; + name: IPropertyValue; + lineage: string[]; + ambiguities?: RemotePlace[]; + + // sadly, sometimes invalid or uncreated objects "pretend" to be remote + // should reconsider this naming + type: 'remote' | 'local' | 'invalid'; +}; + export default class RemotePlaceCache { private static cache: RemotePlaceDatastore = {}; - public static async getPlacesWithType(chtApi: ChtApi, placeType: string) + public static async getPlacesWithType(chtApi: ChtApi, contactType: ContactType, hierarchyLevel: HierarchyConstraint) : Promise { - const domainStore = await RemotePlaceCache.getDomainStore(chtApi, placeType); + const domainStore = await RemotePlaceCache.getDomainStore(chtApi, contactType, hierarchyLevel); return domainStore; } - public static async add(place: Place, chtApi: ChtApi): Promise { - const domainStore = await RemotePlaceCache.getDomainStore(chtApi, place.type.name); - domainStore.push(place.asRemotePlace()); + public static add(place: Place, chtApi: ChtApi): void { + const { domain } = chtApi.chtSession.authInfo; + const placeType = place.type.name; + + const places = RemotePlaceCache.cache[domain]?.[placeType]; + // if there is no cache existing, discard the value + // it will be fetched if needed when the cache is built + if (places) { + places.push(place.asRemotePlace()); + } } public static clear(chtApi: ChtApi, contactTypeName?: string): void { @@ -29,19 +50,19 @@ export default class RemotePlaceCache { RemotePlaceCache.cache = {}; } else if (!contactTypeName) { delete RemotePlaceCache.cache[domain]; - } else { + } else if (RemotePlaceCache.cache[domain]) { delete RemotePlaceCache.cache[domain][contactTypeName]; } } - private static async getDomainStore(chtApi: ChtApi, placeType: string) + private static async getDomainStore(chtApi: ChtApi, contactType: ContactType, hierarchyLevel: HierarchyConstraint) : Promise { const { domain } = chtApi.chtSession.authInfo; + const placeType = hierarchyLevel.contact_type; const { cache: domainCache } = RemotePlaceCache; - const places = domainCache[domain]?.[placeType]; if (!places) { - const fetchPlacesWithType = chtApi.getPlacesWithType(placeType); + const fetchPlacesWithType = RemotePlaceCache.fetchRemotePlaces(chtApi, contactType, hierarchyLevel); domainCache[domain] = { ...domainCache[domain], [placeType]: await fetchPlacesWithType, @@ -50,4 +71,22 @@ export default class RemotePlaceCache { return domainCache[domain][placeType]; } + + private static async fetchRemotePlaces(chtApi: ChtApi, contactType: ContactType, hierarchyLevel: HierarchyConstraint): Promise { + function extractLineage(doc: any): string[] { + if (doc?.parent) { + return [doc.parent._id, ...extractLineage(doc.parent)]; + } + + return []; + } + + const docs = await chtApi.getPlacesWithType(hierarchyLevel.contact_type); + return docs.map((doc: any): RemotePlace => ({ + id: doc._id, + name: new NamePropertyValue(doc.name, hierarchyLevel), + lineage: extractLineage(doc), + type: 'remote', + })); + } } diff --git a/src/lib/remote-place-resolver.ts b/src/lib/remote-place-resolver.ts index e7d10a57..ce383d56 100644 --- a/src/lib/remote-place-resolver.ts +++ b/src/lib/remote-place-resolver.ts @@ -1,10 +1,11 @@ import _ from 'lodash'; import Place from '../services/place'; +import { IPropertyValue } from '../property-value'; import SessionCache from '../services/session-cache'; -import { RemotePlace, ChtApi } from './cht-api'; -import { Config, ContactType, HierarchyConstraint } from '../config'; -import { Validation } from './validation'; -import RemotePlaceCache from './remote-place-cache'; +import { ChtApi } from './cht-api'; +import { Config, HierarchyConstraint } from '../config'; +import RemotePlaceCache, { RemotePlace } from './remote-place-cache'; +import { UnvalidatedPropertyValue } from '../property-value'; type RemotePlaceMap = { [key: string]: RemotePlace }; @@ -13,8 +14,11 @@ export type PlaceResolverOptions = { }; export default class RemotePlaceResolver { - public static readonly NoResult: RemotePlace = { id: 'na', name: 'Place Not Found', type: 'invalid', lineage: [] }; - public static readonly Multiple: RemotePlace = { id: 'multiple', name: 'multiple places', type: 'invalid', lineage: [] }; + public static readonly NoResult: RemotePlace = + { id: 'na', name: new UnvalidatedPropertyValue('Place Not Found'), type: 'invalid', lineage: [] }; + + public static readonly Multiple: RemotePlace = + { id: 'multiple', name: new UnvalidatedPropertyValue('multiple places'), type: 'invalid', lineage: [] }; public static resolve = async ( places: Place[], @@ -38,36 +42,34 @@ export default class RemotePlaceResolver { // #91 - for editing: forget previous resolution delete place.resolvedHierarchy[hierarchyLevel.level]; - if (!place.hierarchyProperties[hierarchyLevel.property_name]) { + if (!place.hierarchyProperties[hierarchyLevel.property_name]?.original) { continue; } - const fuzzFunction = getFuzzFunction(place, hierarchyLevel, place.type); const mapIdToDetails = {}; if (hierarchyLevel.level > 0) { // no replacing local places - const searchKeys = getSearchKeys(place, hierarchyLevel.property_name, fuzzFunction, false); + const searchKeys = getSearchKeys(place, hierarchyLevel.property_name); for (const key of searchKeys) { - const localResult = findLocalPlaces(key, hierarchyLevel.contact_type, sessionCache, options, fuzzFunction); + const localResult = findLocalPlaces(key, hierarchyLevel.contact_type, sessionCache, options); if (localResult) { - addKeyToMap(mapIdToDetails, key, localResult); + addKeyToMap(mapIdToDetails, key.original, localResult); } } } const placesFoundRemote = await findRemotePlacesInHierarchy(place, hierarchyLevel, chtApi); placesFoundRemote.forEach(remotePlace => { - addKeyToMap(mapIdToDetails, remotePlace.name, remotePlace); + addKeyToMap(mapIdToDetails, remotePlace.name.original, remotePlace); if (options?.fuzz) { - const alteredName = fuzzFunction(remotePlace.name); - if (remotePlace.name !== alteredName) { - addKeyToMap(mapIdToDetails, alteredName, remotePlace); + if (remotePlace.name.original !== remotePlace.name.formatted) { + addKeyToMap(mapIdToDetails, remotePlace.name.formatted, remotePlace); } } }); const placeName = place.hierarchyProperties[hierarchyLevel.property_name]; - place.resolvedHierarchy[hierarchyLevel.level] = pickFromMapOptimistic(mapIdToDetails, placeName, fuzzFunction, !!options?.fuzz); + place.resolvedHierarchy[hierarchyLevel.level] = pickFromMapOptimistic(mapIdToDetails, placeName, !!options?.fuzz); } await RemotePlaceResolver.resolveAmbiguousParent(place); @@ -109,21 +111,12 @@ export default class RemotePlaceResolver { }; } -function getFuzzFunction(place: Place, hierarchyLevel: HierarchyConstraint, contactType: ContactType) { - const fuzzingProperty = hierarchyLevel.level === 0 ? contactType.replacement_property : hierarchyLevel; - if (fuzzingProperty.type === 'generated') { - throw Error(`Invalid configuration: hierarchy properties cannot be of type "generated".`); - } - - return (val: string) => Validation.formatSingle(place, fuzzingProperty, val); -} - async function findRemotePlacesInHierarchy( place: Place, hierarchyLevel: HierarchyConstraint, chtApi: ChtApi ) : Promise { - let searchPool = await RemotePlaceCache.getPlacesWithType(chtApi, hierarchyLevel.contact_type); + let searchPool = await RemotePlaceCache.getPlacesWithType(chtApi, place.type, hierarchyLevel); searchPool = searchPool.filter(remotePlace => chtApi.chtSession.isPlaceAuthorized(remotePlace)); const topDownHierarchy = Config.getHierarchyWithReplacement(place.type, 'desc'); @@ -152,49 +145,43 @@ async function findRemotePlacesInHierarchy( return searchPool; } -function getSearchKeys(place: Place, searchPropertyName: string, fuzzFunction: (key: string) => string, fuzz: boolean) - : string[] { +function getSearchKeys(place: Place, searchPropertyName: string) + : IPropertyValue[] { const keys = []; const key = place.hierarchyProperties[searchPropertyName]; if (key) { keys.push(key); } - - if (fuzz) { - keys.push(fuzzFunction(key)); - } - - return _.uniq(keys); + + return _.uniqBy(keys, 'formatted'); } -function pickFromMapOptimistic(map: RemotePlaceMap, placeName: string, fuzzFunction: (key: string) => string, fuzz: boolean) +function pickFromMapOptimistic(map: RemotePlaceMap, placeName: IPropertyValue, fuzz: boolean) : RemotePlace | undefined { if (!placeName) { return; } - const result = map[placeName.toLowerCase()]; + const result = map[placeName.original.toLowerCase()]; if (!fuzz) { return result; } - const fuzzyName = fuzzFunction(placeName); - const fuzzyResult = map[fuzzyName.toLowerCase()]; + const fuzzyResult = map[placeName.formatted.toLowerCase()]; const [optimisticResult] = [result, fuzzyResult].filter(r => r && r.type !== 'invalid'); return optimisticResult || result || fuzzyResult || RemotePlaceResolver.NoResult; } function findLocalPlaces( - name: string, + name: IPropertyValue, type: string, sessionCache: SessionCache, - options: PlaceResolverOptions | undefined, - fuzzFunction: (key: string) => string + options: PlaceResolverOptions | undefined ): RemotePlace | undefined { - let places = sessionCache.getPlaces({ type, nameExact: name }); + let places = sessionCache.getPlaces({ type, nameExact: name.original }); if (options?.fuzz && !places.length) { - places = sessionCache.getPlaces({ type, nameExact: fuzzFunction(name) }); + places = sessionCache.getPlaces({ type, nameExact: name.formatted }); } if (places.length > 1) { diff --git a/src/lib/search.ts b/src/lib/search.ts index dee99799..14c2963c 100644 --- a/src/lib/search.ts +++ b/src/lib/search.ts @@ -1,7 +1,8 @@ import _ from 'lodash'; import SessionCache from '../services/session-cache'; -import { ChtApi, RemotePlace } from './cht-api'; -import RemotePlaceCache from './remote-place-cache'; +import { ChtApi } from './cht-api'; +import { PropertyValues } from '../property-value'; +import RemotePlaceCache, { RemotePlace } from './remote-place-cache'; import RemotePlaceResolver from './remote-place-resolver'; import { Config, ContactType, HierarchyConstraint } from '../config'; import Place from '../services/place'; @@ -23,7 +24,7 @@ export default class SearchLib { const searchResults: RemotePlace[] = _.uniqWith([ ...localResults.map(r => r.asRemotePlace()), ...remoteResults, - ], (placeA: RemotePlace, placeB: RemotePlace) => placeA.name === placeB.name && placeA.type === placeB.type); + ], (placeA: RemotePlace, placeB: RemotePlace) => placeA.name.formatted === placeB.name.formatted && placeA.type === placeB.type); if (searchResults.length === 0) { searchResults.push(RemotePlaceResolver.NoResult); @@ -56,9 +57,9 @@ async function getRemoteResults( chtApi: ChtApi, dataPrefix: string ) : Promise { - let remoteResults = (await RemotePlaceCache.getPlacesWithType(chtApi, hierarchyLevel.contact_type)) + let remoteResults = (await RemotePlaceCache.getPlacesWithType(chtApi, contactType, hierarchyLevel)) .filter(remotePlace => chtApi.chtSession.isPlaceAuthorized(remotePlace)) - .filter(place => place.name.includes(searchString)); + .filter(place => PropertyValues.includes(place.name, searchString)); const topDownHierarchy = Config.getHierarchyWithReplacement(contactType, 'desc'); for (const constrainingHierarchy of topDownHierarchy) { @@ -66,14 +67,14 @@ async function getRemoteResults( break; } - const searchStringAtLevel = formData[`${dataPrefix}${constrainingHierarchy.property_name}`]?.toLowerCase(); + const searchStringAtLevel = formData[`${dataPrefix}${constrainingHierarchy.property_name}`]; if (!searchStringAtLevel) { continue; } - const placesAtLevel = await RemotePlaceCache.getPlacesWithType(chtApi, constrainingHierarchy.contact_type); + const placesAtLevel = await RemotePlaceCache.getPlacesWithType(chtApi, contactType, constrainingHierarchy); const relevantPlaceIds = placesAtLevel - .filter(remotePlace => remotePlace.name.includes(searchStringAtLevel)) + .filter(remotePlace => PropertyValues.includes(remotePlace.name, searchStringAtLevel)) .map(remotePlace => remotePlace.id); const hierarchyIndex = constrainingHierarchy.level - hierarchyLevel.level - 1; remoteResults = remoteResults.filter(result => relevantPlaceIds.includes(result.lineage[hierarchyIndex])); diff --git a/src/lib/validation.ts b/src/lib/validation.ts deleted file mode 100644 index 031a630f..00000000 --- a/src/lib/validation.ts +++ /dev/null @@ -1,205 +0,0 @@ -import _ from 'lodash'; -import { Config, ContactProperty } from '../config'; -import Place from '../services/place'; -import RemotePlaceResolver from './remote-place-resolver'; -import { RemotePlace } from './cht-api'; - -import ValidatorDateOfBirth from './validator-dob'; -import ValidatorGenerated from './validator-generated'; -import ValidatorName from './validator-name'; -import ValidatorPhone from './validator-phone'; -import ValidatorRegex from './validator-regex'; -import ValidatorSelectMultiple from './validator-select-multiple'; -import ValidatorSelectOne from './validator-select-one'; -import ValidatorSkip from './validator-skip'; -import ValidatorString from './validator-string'; - -export type ValidationError = { - property_name: string; - description: string; -}; - -export interface IValidator { - isValid(input: string, property? : ContactProperty) : boolean | string; - format(input : string, property? : ContactProperty) : string; - get defaultError(): string; -} - -type ValidatorMap = { - [key: string]: IValidator; -}; - -const TypeValidatorMap: ValidatorMap = { - dob: new ValidatorDateOfBirth(), - generated: new ValidatorGenerated(), - name: new ValidatorName(), - none: new ValidatorSkip(), - phone: new ValidatorPhone(), - regex: new ValidatorRegex(), - string: new ValidatorString(), - select_one: new ValidatorSelectOne(), - select_multiple: new ValidatorSelectMultiple(), -}; - -export class Validation { - public static getValidationErrors(place: Place) : ValidationError[] { - const requiredColumns = Config.getRequiredColumns(place.type, place.isReplacement); - const result = [ - ...Validation.validateHierarchy(place), - ...Validation.validateProperties(place.properties, place.type.place_properties, requiredColumns, 'place_'), - ...Validation.validateProperties(place.contact.properties, place.type.contact_properties, requiredColumns, 'contact_'), - ...Validation.validateProperties(place.userRoleProperties, [Config.getUserRoleConfig(place.type)], requiredColumns, 'user_') - ]; - - return result; - } - - public static format(place: Place): void { - const doFormatting = (withGenerators: boolean) => { - const isGenerator = (property: ContactProperty) => property.type === 'generated'; - const alterAllProperties = (propertiesToAlter: ContactProperty[], objectToAlter: any) => { - for (const property of propertiesToAlter) { - if (isGenerator(property) === withGenerators) { - this.alterProperty(place, property, objectToAlter); - } - } - }; - - alterAllProperties(place.type.contact_properties, place.contact.properties); - alterAllProperties(place.type.place_properties, place.properties); - for (const hierarchy of Config.getHierarchyWithReplacement(place.type)) { - this.alterProperty(place, hierarchy, place.hierarchyProperties); - } - }; - - doFormatting(false); - doFormatting(true); - } - - public static formatSingle(place: Place, propertyMatch: ContactProperty, val: string): string { - const object = { [propertyMatch.property_name]: val }; - Validation.alterProperty(place, propertyMatch, object); - return object[propertyMatch.property_name]; - } - - private static validateHierarchy(place: Place): ValidationError[] { - const result: ValidationError[] = []; - - const hierarchy = Config.getHierarchyWithReplacement(place.type); - hierarchy.forEach((hierarchyLevel, index) => { - const data = place.hierarchyProperties[hierarchyLevel.property_name]; - - if (hierarchyLevel.level !== 0 || data) { - const isExpected = hierarchyLevel.required; - const resolution = place.resolvedHierarchy[hierarchyLevel.level]; - const isValid = resolution?.type !== 'invalid' && ( - !isExpected || - resolution?.type === 'remote' || - resolution?.type === 'local' - ); - if (!isValid) { - const levelUp = hierarchy[index + 1]?.property_name; - result.push({ - property_name: `hierarchy_${hierarchyLevel.property_name}`, - description: this.describeInvalidRemotePlace( - resolution, - hierarchyLevel.contact_type, - data, - place.hierarchyProperties[levelUp] - ), - }); - } - } - }); - - return result; - } - - private static validateProperties( - obj : any, - properties : ContactProperty[], - requiredProperties: ContactProperty[], - prefix: string - ) : ValidationError[] { - const invalid: ValidationError[] = []; - - for (const property of properties) { - const value = obj[property.property_name]; - - const isRequired = requiredProperties.some((prop) => _.isEqual(prop, property)); - const errorPropertyName = `${prefix}${property.property_name}`; - if (value === undefined && isRequired) { - invalid.push({ - property_name: errorPropertyName, - description: 'Is Required', - }); - - continue; - } - - if (value || isRequired) { - const isValid = Validation.isValid(property, value); - if (isValid === false || typeof isValid === 'string') { - invalid.push({ - property_name: errorPropertyName, - description: isValid === false ? 'Value is invalid' : isValid as string, - }); - } - } - } - - return invalid; - } - - private static isValid(property : ContactProperty, value: string) : boolean | string { - const validator = this.getValidator(property); - try { - const isValid = validator.isValid(value, property); - return isValid === false ? property.errorDescription || validator.defaultError : isValid; - } catch (e) { - const error = `Error in isValid for '${property.type}': ${e}`; - console.log(error); - return error; - } - } - - private static alterProperty(place: Place, property : ContactProperty, obj: any) { - const value = obj[property.property_name]; - const validator = this.getValidator(property); - if (validator instanceof ValidatorGenerated) { - const altered = validator.format(place, property); - obj[property.property_name] = altered; - } else if (value) { - const altered = validator.format(value, property); - obj[property.property_name] = altered; - } - } - - private static getValidator(property: ContactProperty) : IValidator { - const validator = TypeValidatorMap[property.type]; - if (!validator) { - throw Error(`unvalidatable type: '${property.friendly_name}' has type '${property.type}'`); - } - - return validator; - } - - private static describeInvalidRemotePlace( - remotePlace: RemotePlace | undefined, - friendlyType: string, - searchStr?: string, - requiredParent?: string - ): string { - if (!searchStr) { - return `Cannot find ${friendlyType} because the search string is empty`; - } - - const requiredParentSuffix = requiredParent ? ` under '${requiredParent}'` : ''; - if (RemotePlaceResolver.Multiple.id === remotePlace?.id) { - const ambiguityDetails = JSON.stringify(remotePlace.ambiguities?.map(a => a.id)); - return `Found multiple ${friendlyType}s matching '${searchStr}'${requiredParentSuffix} ${ambiguityDetails}`; - } - - return `Cannot find '${friendlyType}' matching '${searchStr}'${requiredParentSuffix}`; - } -} diff --git a/src/liquid/place/create_form.html b/src/liquid/place/create_form.html index 71a9bcad..23fa16dd 100644 --- a/src/liquid/place/create_form.html +++ b/src/liquid/place/create_form.html @@ -5,7 +5,7 @@
{% for hierarchy in hierarchy %} - {% if hierarchy.level != 0 or op == 'replace' or place.hierarchyProperties.replacement %} + {% if hierarchy.level != 0 or op == 'replace' or place.hierarchyProperties.replacement.original %} {% include "components/search_input.html" type=contactType.name diff --git a/src/liquid/place/move_form.html b/src/liquid/place/move_form.html index 2788ed5a..59fb15c5 100644 --- a/src/liquid/place/move_form.html +++ b/src/liquid/place/move_form.html @@ -54,7 +54,12 @@

To