Skip to content
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
77 changes: 77 additions & 0 deletions src/data/wmr-form/WmrFormD2Repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { D2Api } from "../../types/d2-api";
import { WmrFormRepository } from "../../domain/wmr-form/repositories/WmrFormRepository";
import { WmrForm } from "../../domain/wmr-form/entities/WmrForm";
import { Id } from "../../domain/common/entities/Base";
import { PersistOptions } from "../../domain/wmr-form/repositories/WmrQuestionRepository";
import { generateMetadataOptions } from "./WmrQuestionD2Repository";
import { writeFileSync } from "fs";

export class WmrFormD2Repository implements WmrFormRepository {
constructor(private api: D2Api) {}

async getById(id: string): Promise<WmrForm> {
const response = await this.api.models.dataSets
.get({
fields: {
id: true,
displayName: true,
dataSetElements: { dataElement: { id: true }, categoryCombo: { id: true } },
},
filter: { id: { eq: id } },
})
.response();

const d2DataSet = response.data.objects[0];
if (!d2DataSet) throw new Error(`DataSet with id ${id} not found`);
const defaultCategoryCombo = await this.getDefaultCategoryCombo();

return {
id: d2DataSet.id,
name: d2DataSet.displayName,
questionRefs: d2DataSet.dataSetElements.map(element => ({
questionId: element.dataElement.id,
combinationId: element.categoryCombo ? element.categoryCombo.id : defaultCategoryCombo,
})),
};
}

private async getDefaultCategoryCombo(): Promise<Id> {
const response = await this.api.models.categoryCombos
.get({ fields: { id: true }, filter: { name: { eq: "default" } } })
.getData();

const defaultCategoryCombo = response.objects[0];
if (!defaultCategoryCombo) throw new Error("Default category combo not found");

return defaultCategoryCombo.id;
}

async save(form: WmrForm, options: PersistOptions): Promise<void> {
const { objects } = await this.api.models.dataSets
.get({ fields: { $owner: true }, filter: { id: { eq: form.id } }, paging: false })
.getData();

const d2DataSet = objects[0];

const dataElementsToAdd = form.questionRefs.map(element => ({
dataSet: { id: form.id },
dataElement: { id: element.questionId },
categoryCombo: { id: element.combinationId },
}));

const dataSetToSave = { ...(d2DataSet || {}), dataSetElements: dataElementsToAdd };

if (options.export) {
const currentDate = new Date().toISOString();
writeFileSync(
`metadata_dataSets_${currentDate}.json`,
JSON.stringify({ dataSets: dataSetToSave }, null, 2)
);
}

const response = await this.api.metadata
.post({ dataSets: [dataSetToSave] }, generateMetadataOptions(options))
.getData();
console.debug("dataSets response", response.stats);
}
}
79 changes: 79 additions & 0 deletions src/data/wmr-form/WmrQuestionD2Repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import _ from "lodash";
import { D2Api, PostOptions } from "../../types/d2-api";
import { WmrQuestion } from "../../domain/wmr-form/entities/WmrQuestion";
import { PersistOptions, WmrQuestionRepository } from "../../domain/wmr-form/repositories/WmrQuestionRepository";
import { promiseMap } from "../../utils/promises";
import { Id } from "../../domain/common/entities/Base";
import { writeFileSync } from "fs";

export class WmrQuestionD2Repository implements WmrQuestionRepository {
constructor(private api: D2Api) {}

async getByIds(ids: string[]): Promise<WmrQuestion[]> {
const allDataElements = await promiseMap(_.chunk(ids, CHUNK_SIZE), async dataElementIds => {
const { objects } = await this.api.models.dataElements
.get({
fields: { id: true, name: true, shortName: true, code: true, categoryCombo: { id: true } },
filter: { id: { in: dataElementIds } },
paging: false,
})
.getData();
return objects.map(d2DataElement => ({
...d2DataElement,
combinationId: d2DataElement.categoryCombo.id,
}));
});
return allDataElements.flat();
}

async clone(questions: WmrQuestion[], idsToClone: Id[], options: PersistOptions): Promise<void> {
if (questions.length === 0 || idsToClone.length === 0) return Promise.resolve();

const dataElementsToClone = await promiseMap(_.chunk(idsToClone, CHUNK_SIZE), async dataElementIds => {
const { objects } = await this.api.models.dataElements
.get({ fields: { $owner: true }, filter: { id: { in: dataElementIds } }, paging: false })
.getData();
return objects;
});

const allDataElementsToClone = dataElementsToClone.flat();
const dataElementsById = _(allDataElementsToClone)
.keyBy(dataElement => dataElement.id.toUpperCase())
.value();

const onlyQuestionsInCloneIds = questions.filter(question => Boolean(dataElementsById[question.id]));

await promiseMap(_.chunk(onlyQuestionsInCloneIds, CHUNK_SIZE), async questionsChunk => {
const d2DataElementsToSave = questionsChunk.map(question => {
const existinDataElement = dataElementsById[question.id];
const d2DataElementToSave = {
...(existinDataElement || {}),
id: question.id,
name: question.name,
shortName: question.shortName,
code: question.code,
};
return d2DataElementToSave;
});

if (options.export) {
const currentDate = new Date().toISOString();
writeFileSync(
`metadata_dataElements_${currentDate}.json`,
JSON.stringify({ dataElements: d2DataElementsToSave }, null, 2)
);
}

const response = await this.api.metadata
.post({ dataElements: d2DataElementsToSave }, generateMetadataOptions(options))
.getData();
console.debug("dataElements response", response.stats);
});
}
}

const CHUNK_SIZE = 100;

export function generateMetadataOptions(options: PersistOptions): Partial<PostOptions> {
return { importMode: options.persist ? "COMMIT" : "VALIDATE", skipValidation: !options.persist };
}
101 changes: 101 additions & 0 deletions src/domain/common/entities/Either.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Either a success value or an error. Example:
*
* ```
* Either.success<{ message: string }, string>("9")
* .map(s => parseInt(s))
* .flatMap(x => {
* return x > 0 ? Either.success(Math.sqrt(x)) : Either.error({ message: "negative!" });
* })
* .match({
* success: x => console.log(`Value is ${x}`),
* error: error => console.error(`Some error: ${error.message}`),
* }); // prints `Value is 3`
* ```
*/

export class Either<Error, Data> {
constructor(public value: EitherValue<Error, Data>) {}

get(errorMessage?: string): Data {
return this.getOrThrow(errorMessage);
}

getOrThrow(errorMessage?: string): Data {
const throwFn = () => {
throw Error(
errorMessage ? errorMessage : "An error has ocurred retrieving value: " + JSON.stringify(this.value)
);
};

return this.match({
error: () => throwFn(),
success: value => value,
});
}

match<Res>(matchObj: MatchObject<Error, Data, Res>): Res {
switch (this.value.type) {
case "success":
return matchObj.success(this.value.data);
case "error":
return matchObj.error(this.value.error);
}
}

isError(): this is this & { value: EitherValueError<Error> } {
return this.value.type === "error";
}

isSuccess(): this is this & { value: EitherValueSuccess<Data> } {
return this.value.type === "success";
}

map<Data1>(fn: (data: Data) => Data1): Either<Error, Data1> {
return this.flatMap(data => new Either<Error, Data1>({ type: "success", data: fn(data) }));
}

mapError<Error1>(fn: (error: Error) => Error1): Either<Error1, Data> {
return this.flatMapError(error => new Either<Error1, Data>({ type: "error", error: fn(error) }));
}

flatMap<Data1>(fn: (data: Data) => Either<Error, Data1>): Either<Error, Data1> {
return this.match({
success: data => fn(data),
error: () => this as Either<Error, any>,
});
}

flatMapError<Error1>(fn: (error: Error) => Either<Error1, Data>): Either<Error1, Data> {
return this.match({
success: () => this as Either<any, Data>,
error: error => fn(error),
});
}

static error<Error>(error: Error) {
return new Either<Error, never>({ type: "error", error });
}

static success<Error, Data>(data: Data) {
return new Either<Error, Data>({ type: "success", data });
}

static map2<Error, Res, Data1, Data2>(
[either1, either2]: [Either<Error, Data1>, Either<Error, Data2>],
fn: (data1: Data1, data2: Data2) => Res
): Either<Error, Res> {
return either1.flatMap<Res>(data1 => {
return either2.map<Res>(data2 => fn(data1, data2));
});
}
}

type EitherValueError<Error> = { type: "error"; error: Error; data?: never };
type EitherValueSuccess<Data> = { type: "success"; error?: never; data: Data };
type EitherValue<Error, Data> = EitherValueError<Error> | EitherValueSuccess<Data>;

type MatchObject<Error, Data, Res> = {
success: (data: Data) => Res;
error: (error: Error) => Res;
};
45 changes: 45 additions & 0 deletions src/domain/common/entities/Struct.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Base class for typical classes with attributes. Features: create, update.
*
* ```
* class Counter extends Struct<{ id: Id; value: number }>() {
* add(value: number): Counter {
* return this._update({ value: this.value + value });
* }
* }
*
* const counter1 = Counter.create({ id: "some-counter", value: 1 });
* const counter2 = counter1._update({ value: 2 });
* ```
*/

export function Struct<Attrs>() {
abstract class Base {
constructor(_attributes: Attrs) {
Object.assign(this, _attributes);
}

_getAttributes(): Attrs {
const entries = Object.getOwnPropertyNames(this).map(key => [key, (this as any)[key]]);
return Object.fromEntries(entries) as Attrs;
}

protected _update(partialAttrs: Partial<Attrs>): this {
const ParentClass = this.constructor as new (values: Attrs) => typeof this;
return new ParentClass({ ...this._getAttributes(), ...partialAttrs });
}

static create<U extends Base>(this: new (attrs: Attrs) => U, attrs: Attrs): U {
return new this(attrs);
}
}

return Base as {
new (values: Attrs): Attrs & Base;
create: typeof Base["create"];
};
}

const GenericStruct = Struct<unknown>();

export type GenericStructInstance = InstanceType<typeof GenericStruct>;
24 changes: 24 additions & 0 deletions src/domain/common/entities/ValidationError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import i18n from "../../../locales";

export type ValidationErrorKey = "field_cannot_be_blank" | "not_match";

export const validationErrorMessages: Record<ValidationErrorKey, (fieldName: string) => string> = {
field_cannot_be_blank: (fieldName: string) =>
i18n.t(`Cannot be blank: {{fieldName}}`, { fieldName: fieldName, nsSeparator: false }),
not_match: (fieldName: string) => i18n.t(`Not match: {{fieldName}}`, { fieldName: fieldName, nsSeparator: false }),
};

export function getErrors<T>(errors: ValidationError<T>[]) {
return errors
.map(error => {
return error.errors.map(err => validationErrorMessages[err](error.property as string));
})
.flat()
.join("\n");
}

export type ValidationError<T> = {
property: keyof T;
value: unknown;
errors: ValidationErrorKey[];
};
61 changes: 61 additions & 0 deletions src/domain/wmr-form/entities/WmrForm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Either } from "../../common/entities/Either";
import { Struct } from "../../common/entities/Struct";
import { ValidationError } from "../../common/entities/ValidationError";
import { Id } from "./../../common/entities/Base";
import { WmrQuestion } from "./WmrQuestion";

export type WmrForm = { id: Id; name: string; questionRefs: Array<{ questionId: Id; combinationId: Id }> };

type CopyApprovalFormAttrs = {
originalForm: WmrForm & { questions: WmrQuestion[] };
targetForm: WmrForm & { questions: WmrQuestion[] };
suffix: string;
};

export class ApprovalForm extends Struct<CopyApprovalFormAttrs>() {
static build(data: CopyApprovalFormAttrs): Either<ValidationError<ApprovalForm>[], ApprovalForm> {
const errorsSuffix = this.validateSuffixInName(data);

if (errorsSuffix.length > 0) {
return Either.error(errorsSuffix);
}

if (!data.suffix) {
return Either.error([
{
errors: ["field_cannot_be_blank"],
property: "suffix",
value: data.suffix,
},
]);
}

return Either.success(this.create(data));
}

findMissingQuestions = (): WmrQuestion[] => {
const suffix = this.suffix;
const filteredTarget = this.targetForm.questions.filter(target => target.name.endsWith(suffix));
if (filteredTarget.length === 0) return [];

return this.originalForm.questions.filter(
original => !filteredTarget.some(target => target.name === `${original.name}${suffix}`)
);
};

private static validateSuffixInName(data: CopyApprovalFormAttrs): ValidationError<ApprovalForm>[] {
const originalNameWithSuffix = `${data.originalForm.name}${data.suffix}`;

if (originalNameWithSuffix !== data.targetForm.name) {
return [
{
errors: ["not_match"],
property: "originalForm",
value: data.originalForm.name,
},
];
}

return [];
}
}
3 changes: 3 additions & 0 deletions src/domain/wmr-form/entities/WmrQuestion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Id } from "../../common/entities/Base";

export type WmrQuestion = { id: Id; name: string; shortName: string; code: string; combinationId: Id };
Loading