From 8c4f3c808cf5ad607ecf4a37368d5dd075014c9c Mon Sep 17 00:00:00 2001 From: jonathanbataire Date: Tue, 14 Jan 2025 10:01:46 +0300 Subject: [PATCH] working version --- package-lock.json | 21 ++++---- package.json | 2 +- src/config/chis-ke/gross.ts | 2 +- src/config/chis-ug/config.json | 87 ---------------------------------- src/config/config-factory.ts | 51 ++++++++++++++++++-- src/config/index.ts | 39 ++++++++++----- src/lib/remote-place-cache.ts | 2 +- src/liquid/app/nav.html | 2 +- src/routes/add-place.ts | 9 ++-- src/routes/app.ts | 45 ++++++++++-------- src/routes/authentication.ts | 2 +- src/routes/files.ts | 6 +-- src/routes/manage-hierarchy.ts | 4 +- src/routes/search.ts | 4 +- 14 files changed, 122 insertions(+), 154 deletions(-) diff --git a/package-lock.json b/package-lock.json index ecefb0a6..8c677317 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@fastify/cookie": "^9.2.0", "@fastify/env": "^4.2.0", "@fastify/formbody": "^7.4.0", - "@fastify/multipart": "^8.0.0", + "@fastify/multipart": "^8.3.0", "@fastify/static": "^7.0.1", "@fastify/view": "^8.2.0", "@types/html-minifier": "^4.0.5", @@ -266,11 +266,9 @@ "license": "MIT" }, "node_modules/@fastify/busboy": { - "version": "1.2.1", - "license": "MIT", - "dependencies": { - "text-decoding": "^1.0.0" - }, + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", "engines": { "node": ">=14" } @@ -343,10 +341,11 @@ } }, "node_modules/@fastify/multipart": { - "version": "8.1.0", - "license": "MIT", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-8.3.0.tgz", + "integrity": "sha512-A8h80TTyqUzaMVH0Cr9Qcm6RxSkVqmhK/MVBYHYeRRSUbUYv08WecjWKSlG2aSnD4aGI841pVxAjC+G1GafUeQ==", "dependencies": { - "@fastify/busboy": "^1.0.0", + "@fastify/busboy": "^2.1.0", "@fastify/deepmerge": "^1.0.0", "@fastify/error": "^3.0.0", "fastify-plugin": "^4.0.0", @@ -6682,10 +6681,6 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, - "node_modules/text-decoding": { - "version": "1.0.0", - "license": "MIT" - }, "node_modules/text-table": { "version": "0.2.0", "license": "MIT" diff --git a/package.json b/package.json index 462b7415..8ddfe11f 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "@fastify/cookie": "^9.2.0", "@fastify/env": "^4.2.0", "@fastify/formbody": "^7.4.0", - "@fastify/multipart": "^8.0.0", + "@fastify/multipart": "^8.3.0", "@fastify/static": "^7.0.1", "@fastify/view": "^8.2.0", "@types/html-minifier": "^4.0.5", diff --git a/src/config/chis-ke/gross.ts b/src/config/chis-ke/gross.ts index 41a20f2a..cafc13f1 100644 --- a/src/config/chis-ke/gross.ts +++ b/src/config/chis-ke/gross.ts @@ -30,7 +30,7 @@ export default async function mutate(payload: PlacePayload, chtApi: ChtApi, isRe payload[chpKey] = result; }; - const contactType = Config.getContactType(payload.contact_type); + const contactType = await Config.getContactType(payload.contact_type); const { parent: chu, sibling } = await chtApi.getParentAndSibling(payload.parent, contactType); if (!chu && !sibling) { throw Error(`CHU does not exist`); diff --git a/src/config/chis-ug/config.json b/src/config/chis-ug/config.json index f5dd055d..fbbfd50e 100644 --- a/src/config/chis-ug/config.json +++ b/src/config/chis-ug/config.json @@ -15,93 +15,6 @@ } ], "contact_types": [ - { - "name": "c40-parish", - "friendly": "Parish", - "contact_type": "person", - "user_role": ["chew"], - "username_from_place": true, - "deactivate_users_on_replace": true, - "hierarchy": [ - { - "friendly_name": "Health Facility", - "property_name": "HEALTHFACILITY", - "contact_type": "c30-district_hospital", - "type": "name", - "required": true, - "parameter": ["\\sH[ /]*F\\s"], - "level": 1 - } - ], - "replacement_property": { - "friendly_name": "Outgoing CHEW", - "property_name": "REPLACEMENT", - "type": "name", - "required": true - }, - "place_properties": [ - { - "friendly_name": "Parish Name", - "property_name": "name", - "type": "name", - "required": true, - "unique": "parent" - }, - { - "friendly_name": "Sub District", - "property_name": "sub_district", - "parameter": ["\\ssub\\s", "\\sdistrict\\s"], - "type": "name", - "required": false - }, - { - "friendly_name": "County", - "property_name": "county", - "type": "name", - "parameter": ["\\scounty\\s"], - "required": true - }, - { - "friendly_name": "Sub County", - "property_name": "sub_county", - "type": "name", - "parameter": ["\\ssub\\s", "\\scounty\\s"], - "required": true - } - ], - "contact_properties": [ - { - "friendly_name": "CHEW Name", - "property_name": "name", - "type": "name", - "required": true - }, - { - "friendly_name": "Phone Number", - "property_name": "phone", - "type": "phone", - "parameter": "UG", - "required": true, - "unique": "all" - }, - { - "friendly_name": "Date of Birth", - "property_name": "date_of_birth", - "type": "dob", - "required": true - }, - { - "friendly_name": "Sex", - "property_name": "sex", - "type": "select_one", - "parameter": { - "male": "Male", - "female": "Female" - }, - "required": true - } - ] - }, { "name": "c50-health_center", "friendly": "VHT Area", diff --git a/src/config/config-factory.ts b/src/config/config-factory.ts index 243b865b..e1d1fbf4 100644 --- a/src/config/config-factory.ts +++ b/src/config/config-factory.ts @@ -1,24 +1,65 @@ -import { PartnerConfig } from '.'; +import { Config, ConfigSystem, PartnerConfig } from '.'; import ugandaConfig from './chis-ug'; import kenyaConfig from './chis-ke'; import togoConfig from './chis-tg'; import civConfig from './chis-civ'; +import path from 'path'; +import fs from 'fs'; -export const CONFIG_MAP: { [key: string]: PartnerConfig } = { +export const uploadedConfigFilePath: string = path.join(getConfigUploadDirectory(), 'config.json'); + + +export const DEFAULT_CONFIG_MAP: { [key: string]: PartnerConfig } = { 'CHIS-KE': kenyaConfig, 'CHIS-UG': ugandaConfig, 'CHIS-TG': togoConfig, 'CHIS-CIV': civConfig }; -export default function getConfigByKey(key: string = 'CHIS-KE'): PartnerConfig { +export default async function getConfigByKey(key: string = 'CHIS-KE'): Promise { + if (fs.existsSync(uploadedConfigFilePath)) { + const uploadedConfig = await readConfig(); + await Config.assertValid(uploadedConfig); + console.log(`Using uploaded configuration: ${uploadedConfigFilePath}`); + return uploadedConfig; + } + const usingKey = key.toUpperCase(); console.log(`Using configuration: ${key}`); - const result = CONFIG_MAP[usingKey]; + const result = DEFAULT_CONFIG_MAP[usingKey]; if (!result) { - const available = JSON.stringify(Object.keys(CONFIG_MAP)); + const available = JSON.stringify(Object.keys(DEFAULT_CONFIG_MAP)); throw Error(`Failed to start: Cannot find configuration "${usingKey}". Configurations available are ${available}`); } return result; } + +export function getConfigUploadDirectory (): string { + const configDir = path.join(__dirname,'..', 'config_uploads'); + if(!fs.existsSync(configDir)) { + fs.mkdirSync(configDir); + } + return configDir; +} + +export async function writeConfig(jsonConfigData: ConfigSystem): Promise { + try { + const jsonString: string = JSON.stringify(jsonConfigData, null, 2); + await fs.promises.writeFile(uploadedConfigFilePath, jsonString); + } catch (error) { + throw new Error('writeConfig: Failed to write file'); + } +} + +export async function readConfig(): Promise { + try { + const fileContent = await fs.promises.readFile(uploadedConfigFilePath, 'utf-8'); + + const config = JSON.parse(fileContent); + return { config }; + } catch (error) { + console.error('readConfig:Failed to read config file:', error); + throw error; + } +} diff --git a/src/config/index.ts b/src/config/index.ts index 5b2bd4de..e17fb556 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -69,18 +69,21 @@ const { CHT_DEV_HTTP } = process.env; -const partnerConfig = getConfigByKey(CONFIG_NAME); -const { config } = partnerConfig; +async function init(): Promise { + return await getConfigByKey(CONFIG_NAME); +} export class Config { private constructor() {} - public static contactTypes(): ContactType[] { + public static async contactTypes(): Promise { + const {config} = await init(); return config.contact_types; } - public static getContactType(name: string) : ContactType { - const contactMatch = config.contact_types.find(c => c.name === name); + public static async getContactType(name: string) : Promise { + const {config} = await init(); + const contactMatch = config.contact_types.find(c => c?.name === name); if (!contactMatch) { throw new Error(`unrecognized contact type: "${name}"`); } @@ -90,7 +93,7 @@ export class Config { public static getParentProperty(contactType: ContactType): HierarchyConstraint { const parentMatch = contactType.hierarchy.find(c => c.level === 1); if (!parentMatch) { - throw new Error(`hierarchy at level 1 is required: "${contactType.name}"`); + throw new Error(`hierarchy at level 1 is required: "${contactType?.name}"`); } return parentMatch; @@ -136,18 +139,20 @@ export class Config { } public static async mutate(payload: PlacePayload, chtApi: ChtApi, isReplacement: boolean): Promise { + const partnerConfig = await init(); return partnerConfig.mutate && partnerConfig.mutate(payload, chtApi, isReplacement); } - public static getAuthenticationInfo(domain: string) : AuthenticationInfo { - const domainMatch = Config.getDomains().find(c => c.domain === domain); + public static async getAuthenticationInfo(domain: string) : Promise { + const domainMatch = (await Config.getDomains()).find(c => c.domain === domain); if (!domainMatch) { throw new Error(`unrecognized domain: "${domain}"`); } return domainMatch; } - public static getLogoBase64() : string { + public static async getLogoBase64() : Promise { + const {config} = await init(); return config.logoBase64; } @@ -174,7 +179,8 @@ export class Config { ]; } - public static getDomains() : AuthenticationInfo[] { + public static async getDomains() : Promise { + const {config} = await init(); const domains = [...config.domains]; // because all .env vars imported as strings, let's get the AuthenticationInfo object a boolean @@ -194,15 +200,22 @@ export class Config { return _.sortBy(domains, 'friendly'); } - public static getUniqueProperties(contactTypeName: string): ContactProperty[] { + public static async getUniqueProperties(contactTypeName: string): Promise { + const {config} = await init(); const contactMatch = config.contact_types.find(c => c.name === contactTypeName); const uniqueProperties = contactMatch?.place_properties.filter(prop => prop.unique); return uniqueProperties || []; } // TODO: Joi? Chai? - public static assertValid({ config }: PartnerConfig = partnerConfig) { - for (const contactType of config.contact_types) { + public static async assertValid(config?: PartnerConfig) { + const { config: assertionConfig } = config || await init(); + + if (!assertionConfig.contact_types || assertionConfig.contact_types.length === 0) { + throw Error(`invalid configuration: 'contact_types' property is empty`); + } + + for (const contactType of assertionConfig.contact_types) { const allHierarchyProperties = [...contactType.hierarchy, contactType.replacement_property]; const allProperties = [ ...contactType.place_properties, diff --git a/src/lib/remote-place-cache.ts b/src/lib/remote-place-cache.ts index 90d4ee52..e730e14d 100644 --- a/src/lib/remote-place-cache.ts +++ b/src/lib/remote-place-cache.ts @@ -87,7 +87,7 @@ export default class RemotePlaceCache { // fetch docs of type and convert to RemotePlace private static async fetchRemotePlacesAtLevel(chtApi: ChtApi, hierarchyLevel: HierarchyConstraint): Promise { - const uniqueKeyProperties = Config.getUniqueProperties(hierarchyLevel.contact_type); + const uniqueKeyProperties = await Config.getUniqueProperties(hierarchyLevel.contact_type); const docs = await chtApi.getPlacesWithType(hierarchyLevel.contact_type); return docs.map((doc: any) => this.convertContactToRemotePlace(doc, uniqueKeyProperties, hierarchyLevel)); } diff --git a/src/liquid/app/nav.html b/src/liquid/app/nav.html index 76e1bcbc..64771de6 100644 --- a/src/liquid/app/nav.html +++ b/src/liquid/app/nav.html @@ -81,7 +81,7 @@
{% if session.isAdmin %} - settings Upload Config + settings Upload Configuration {% endif %} diff --git a/src/routes/add-place.ts b/src/routes/add-place.ts index 6f228ab0..aaff1a4b 100644 --- a/src/routes/add-place.ts +++ b/src/routes/add-place.ts @@ -13,9 +13,9 @@ export default async function addPlace(fastify: FastifyInstance) { fastify.get('/add-place', async (req, resp) => { const queryParams: any = req.query; - const contactTypes = Config.contactTypes(); + const contactTypes = await Config.contactTypes(); const contactType = queryParams.type - ? Config.getContactType(queryParams.type) + ? await Config.getContactType(queryParams.type) : contactTypes[contactTypes.length - 1]; const op = queryParams.op || 'new'; const tmplData = { @@ -34,7 +34,8 @@ export default async function addPlace(fastify: FastifyInstance) { fastify.post('/place/dob', async (req, resp) => { const { place_type, prefix, prop_type } = req.query as any; - const contactType = Config.getContactType(place_type).contact_properties.find(prop => prop.type === prop_type); + const config = await Config.getContactType(place_type); + const contactType = config.contact_properties.find(prop => prop.type === prop_type); return resp.view('src/liquid/components/contact_type_property.html', { data: req.body, include: { @@ -49,7 +50,7 @@ export default async function addPlace(fastify: FastifyInstance) { fastify.post('/place', async (req, resp) => { const { op, type: placeType } = req.query as any; - const contactType = Config.getContactType(placeType); + const contactType = await Config.getContactType(placeType); const sessionCache: SessionCache = req.sessionCache; const chtApi = new ChtApi(req.chtSession); if (op === 'new' || op === 'replace') { diff --git a/src/routes/app.ts b/src/routes/app.ts index d42c7c0a..ee3a1681 100644 --- a/src/routes/app.ts +++ b/src/routes/app.ts @@ -1,18 +1,19 @@ -import { FastifyInstance } from 'fastify'; +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import Auth from '../lib/authentication'; import { ChtApi } from '../lib/cht-api'; -import { Config } from '../config'; +import { Config, ConfigSystem, PartnerConfig } from '../config'; import DirectiveModel from '../services/directive-model'; import RemotePlaceCache from '../lib/remote-place-cache'; import RemotePlaceResolver from '../lib/remote-place-resolver'; import SessionCache from '../services/session-cache'; import { UploadManager } from '../services/upload-manager'; import WarningSystem from '../warnings'; +import { writeConfig } from '../config/config-factory'; export default async function sessionCache(fastify: FastifyInstance) { fastify.get('/', async (req, resp) => { - const contactTypes = Config.contactTypes(); + const contactTypes = await Config.contactTypes(); const { op = 'table', type: placeTypeName = contactTypes[0].name, @@ -43,7 +44,7 @@ export default async function sessionCache(fastify: FastifyInstance) { }); fastify.get('/app/list', async (req, resp) => { - const contactTypes = Config.contactTypes(); + const contactTypes = await Config.contactTypes(); const sessionCache: SessionCache = req.sessionCache; const directiveModel = new DirectiveModel(sessionCache, req.cookies.filter); const placeData = contactTypes.map((item) => { @@ -80,7 +81,7 @@ export default async function sessionCache(fastify: FastifyInstance) { await RemotePlaceResolver.resolve(places, sessionCache, chtApi, { fuzz: true }); places.forEach(p => p.validate()); - for (const contactType of Config.contactTypes()) { + for (const contactType of await Config.contactTypes()) { await WarningSystem.setWarnings(contactType, chtApi, sessionCache); } @@ -117,15 +118,8 @@ export default async function sessionCache(fastify: FastifyInstance) { resp.header('HX-Redirect', '/'); }); - fastify.get('/app/config', async (req, resp) => { - const contactTypes = Config.contactTypes(); - const placeData = contactTypes.map((item) => { - return { - ...item, - hierarchy: Config.getHierarchyWithReplacement(item, 'desc'), - userRoleProperty: Config.getUserRoleConfig(item), - }; - }); + fastify.get('/app/config', async (req: FastifyRequest, resp: FastifyReply) => { + const contactTypes = await Config.contactTypes(); const tmplData = { view: 'add', logo: Config.getLogoBase64(), @@ -137,17 +131,28 @@ export default async function sessionCache(fastify: FastifyInstance) { return resp.view('src/liquid/app/view.html', tmplData); }); - fastify.post('/app/config', async (req, res) => { + fastify.post('/app/config', async (req: FastifyRequest, res: FastifyReply) => { try { - const fileData = await req.file(); - if (!fileData) { - throw Error('no file data'); + const data = await req.file(); + if (!data) { + res.status(400).send('No file uploaded'); + throw new Error('No file uploaded'); + } + + if (data.mimetype !== 'application/json') { + res.status(400).send('Invalid file type'); + throw new Error('Invalid file type'); } - //const csvBuf = await fileData.toBuffer(); - //await PlaceFactory.createFromCsv(csvBuf, contactType, sessionCache, chtApi); + const fileBuffer = await data.toBuffer(); + const config = JSON.parse(fileBuffer.toString('utf-8')) as ConfigSystem; + + await Config.assertValid({ config }); + await writeConfig(config); + res.header('HX-Redirect', '/'); } catch (error) { + console.error('Route app/config: ', error); return fastify.view('src/liquid/app/config_upload.html', { errors: { message: error, diff --git a/src/routes/authentication.ts b/src/routes/authentication.ts index 6b2f633c..02163f8d 100644 --- a/src/routes/authentication.ts +++ b/src/routes/authentication.ts @@ -30,7 +30,7 @@ export default async function authentication(fastify: FastifyInstance) { const data: any = req.body; const { username, password, domain } = data; - const authInfo = Config.getAuthenticationInfo(domain); + const authInfo = await Config.getAuthenticationInfo(domain); let chtSession; try { chtSession = await ChtSession.create(authInfo, username, password); diff --git a/src/routes/files.ts b/src/routes/files.ts index 73ed7988..d00636df 100644 --- a/src/routes/files.ts +++ b/src/routes/files.ts @@ -20,7 +20,7 @@ export default async function files(fastify: FastifyInstance) { const sessionCache: SessionCache = req.sessionCache; const zip = new JSZip(); - const files = getCredentialsFiles(sessionCache, Config.contactTypes()); + const files = getCredentialsFiles(sessionCache, await Config.contactTypes()); for (const file of files) { zip.file(file.filename, file.content); } @@ -30,8 +30,8 @@ export default async function files(fastify: FastifyInstance) { }); } -function getCsvTemplateColumns(placeType: string) { - const placeTypeConfig = Config.getContactType(placeType); +async function getCsvTemplateColumns(placeType: string) { + const placeTypeConfig = await Config.getContactType(placeType); const hierarchy = Config.getHierarchyWithReplacement(placeTypeConfig); const userRoleConfig = Config.getUserRoleConfig(placeTypeConfig); diff --git a/src/routes/manage-hierarchy.ts b/src/routes/manage-hierarchy.ts index 7dec55f5..ce7e8448 100644 --- a/src/routes/manage-hierarchy.ts +++ b/src/routes/manage-hierarchy.ts @@ -11,7 +11,7 @@ export default async function sessionCache(fastify: FastifyInstance) { const placeType = params.placeType; const contactTypes = Config.contactTypes(); - const contactType = Config.getContactType(placeType); + const contactType = await Config.getContactType(placeType); const tmplData = { view: 'manage-hierarchy', op: params.action, @@ -29,7 +29,7 @@ export default async function sessionCache(fastify: FastifyInstance) { const formData:any = req.body; const sessionCache: SessionCache = req.sessionCache; - const contactType = Config.getContactType(formData.place_type); + const contactType = await Config.getContactType(formData.place_type); const chtApi = new ChtApi(req.chtSession); const tmplData: any = { diff --git a/src/routes/search.ts b/src/routes/search.ts index c39c92b1..3e6557d7 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -23,7 +23,7 @@ export default async function place(fastify: FastifyInstance) { const data: any = req.body; - const contactType = Config.getContactType(type); + const contactType = await Config.getContactType(type); const sessionCache: SessionCache = req.sessionCache; const place = sessionCache.getPlace(placeId); if (!place && op === 'edit') { @@ -64,7 +64,7 @@ export default async function place(fastify: FastifyInstance) { throw new Error('result must be known'); } - const contactType = Config.getContactType(data.place_type); + const contactType = await Config.getContactType(data.place_type); let moveModel; if (HIERARCHY_ACTIONS.includes(op)) { moveModel = hierarchyViewModel(op, contactType);