Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Makes configuration uploadable thru UI #240

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 8 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/config/chis-ke/gross.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
23 changes: 4 additions & 19 deletions src/config/chis-ug/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@
],
"contact_types": [
{
"name": "health_center",
"name": "c50-health_center",
"friendly": "VHT Area",
"contact_type": "person",
"user_role": ["vht"],
"username_from_place": true,
"deactivate_users_on_replace": true,
"hierarchy": [
{
"friendly_name": "Health Center",
"property_name": "HEALTHCENTER",
"contact_type": "district_hospital",
"friendly_name": "Parish",
"property_name": "PARISH",
"contact_type": "c40-parish",
"type": "name",
"required": true,
"parameter": ["\\sH[ /]*C\\s"],
Expand All @@ -49,14 +49,6 @@
"required": true,
"unique": "parent"
},

{
"friendly_name": "District",
"property_name": "district",
"type": "name",
"parameter": ["\\sdistrict\\s"],
"required": true
},
{
"friendly_name": "Sub District",
"property_name": "sub_district",
Expand All @@ -78,13 +70,6 @@
"parameter": ["\\ssub\\s", "\\scounty\\s"],
"required": true
},
{
"friendly_name": "Parish",
"property_name": "parish",
"parameter": ["\\sparish\\s"],
"type": "name",
"required": true
},
{
"friendly_name": "Village",
"property_name": "village",
Expand Down
51 changes: 46 additions & 5 deletions src/config/config-factory.ts
Original file line number Diff line number Diff line change
@@ -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<PartnerConfig> {
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<void> {
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<PartnerConfig> {
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;
}
}
39 changes: 26 additions & 13 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,21 @@ const {
CHT_DEV_HTTP
} = process.env;

const partnerConfig = getConfigByKey(CONFIG_NAME);
const { config } = partnerConfig;
async function init(): Promise<PartnerConfig> {
return await getConfigByKey(CONFIG_NAME);
}

export class Config {
private constructor() {}

public static contactTypes(): ContactType[] {
public static async contactTypes(): Promise<ContactType[]> {
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<ContactType> {
const {config} = await init();
const contactMatch = config.contact_types.find(c => c?.name === name);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clean up

if (!contactMatch) {
throw new Error(`unrecognized contact type: "${name}"`);
}
Expand All @@ -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}"`);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clean up

}

return parentMatch;
Expand Down Expand Up @@ -136,18 +139,20 @@ export class Config {
}

public static async mutate(payload: PlacePayload, chtApi: ChtApi, isReplacement: boolean): Promise<PlacePayload | undefined> {
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<AuthenticationInfo> {
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<string> {
const {config} = await init();
return config.logoBase64;
}

Expand All @@ -174,7 +179,8 @@ export class Config {
];
}

public static getDomains() : AuthenticationInfo[] {
public static async getDomains() : Promise<AuthenticationInfo[]> {
const {config} = await init();
const domains = [...config.domains];

// because all .env vars imported as strings, let's get the AuthenticationInfo object a boolean
Expand All @@ -194,15 +200,22 @@ export class Config {
return _.sortBy(domains, 'friendly');
}

public static getUniqueProperties(contactTypeName: string): ContactProperty[] {
public static async getUniqueProperties(contactTypeName: string): Promise<ContactProperty[]> {
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,
Expand Down
6 changes: 2 additions & 4 deletions src/lib/cht-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ export default class ChtSession {
public readonly axiosInstance: AxiosInstance;
public readonly sessionToken: string;
public readonly chtCoreVersion: string;
public readonly isAdmin: boolean;

private constructor(creationDetails: SessionCreationDetails) {
this.authInfo = creationDetails.authInfo;
this.username = creationDetails.username;
this.facilityIds = creationDetails.facilityIds;
this.sessionToken = creationDetails.sessionToken;
this.chtCoreVersion = creationDetails.chtCoreVersion;
this.isAdmin = this.facilityIds.includes(ADMIN_FACILITY_ID);

this.axiosInstance = axios.create({
baseURL: ChtSession.createUrl(creationDetails.authInfo, ''),
Expand All @@ -47,10 +49,6 @@ export default class ChtSession {
}
}

public get isAdmin(): boolean {
return this.facilityIds.includes(ADMIN_FACILITY_ID);
}

public static async create(authInfo: AuthenticationInfo, username : string, password: string): Promise<ChtSession> {
const sessionToken = await ChtSession.createSessionToken(authInfo, username, password);

Expand Down
2 changes: 1 addition & 1 deletion src/lib/remote-place-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RemotePlace[]> {
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));
}
Expand Down
67 changes: 67 additions & 0 deletions src/liquid/app/config_upload.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<div id="app_config_upload_parent">
<section class="section is-small">
<form
id="app_config_upload"
hx-encoding='multipart/form-data'
autocomplete="off"
hx-post="/app/config"
hx-disabled-elt="button#config_upload_submit, a#cancel"
hx-indicator="#config_upload_submit_progress"
hx-target="this"
hx-swap="outerHTML"
>

{% if errors %}
<div class="notification is-danger">
{{errors.message}}
</div>
{% endif %}

<div id="config-upload-file" class="file has-name is-centered">
<label class="file-label">
<input required class="file-input" type="file" name="resume" accept=".json">
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
<span class="file-label">
Choose a file…
</span>
</span>
<span class="file-name">
No file uploaded
</span>
</label>
</div><br/>
<div class="field is-grouped is-grouped-centered">
<div class="control">
<button id="config_upload_submit" class="button is-link">Upload</button>
</div>
<div class="control">
<a href="/" id="cancel" class="button">Cancel</a>
</div>
</div>
<div>
<progress id="config_upload_submit_progress" class="progress is-small is-primary htmx-indicator" max="100"></progress>
</div>

<div class="field">
<div class="control">
This action will override the current configuration.
For more infomation on how to create a configuration file see
<a target="_blank" href="https://github.com/medic/cht-user-management#using-this-tool-with-your-cht-project">documentation</a>
</div>
</div>

<script>
const fileInput = document.querySelector('#app_config_upload input[type=file]');
fileInput.onchange = () => {
if (fileInput.files.length > 0) {
const fileName = document.querySelector('#app_config_upload .file-name');
fileName.textContent = fileInput.files[0].name;
}
}
</script>
</form>
</section>
</div>
Loading
Loading