src/app/child-dev-project/children/children.service.ts
import { Injectable } from "@angular/core";
import { EntityMapperService } from "../../core/entity/entity-mapper/entity-mapper.service";
import { Note } from "../notes/model/note";
import { ChildSchoolRelation } from "./model/childSchoolRelation";
import moment, { Moment } from "moment";
import { DatabaseIndexingService } from "../../core/entity/database-indexing/database-indexing.service";
import { Entity } from "../../core/entity/model/entity";
import { groupBy } from "../../utils/utils";
@Injectable({ providedIn: "root" })
export class ChildrenService {
constructor(
private entityMapper: EntityMapperService,
private dbIndexing: DatabaseIndexingService,
) {
this.createDatabaseIndices();
}
private createDatabaseIndices() {
this.createNotesIndex();
this.createChildSchoolRelationIndex();
}
/**
* returns a list of children with additional school info
*/
async getChildren(): Promise<Entity[]> {
const children = await this.entityMapper.loadType("Child");
const relations = await this.entityMapper.loadType(ChildSchoolRelation);
groupBy(relations, "childId").forEach(([id, rels]) => {
const child = children.find((c) => c.getId() === id);
if (child) {
this.extendChildWithSchoolInfo(child, rels);
}
});
return children;
}
/**
* returns a child with additional school info
* @param id id of child
*/
async getChild(id: string): Promise<Entity> {
const child = await this.entityMapper.load("Child", id);
const relations = await this.queryRelations(id);
this.extendChildWithSchoolInfo(child, relations);
return child;
}
private extendChildWithSchoolInfo(
child: Entity,
relations: ChildSchoolRelation[],
) {
const active = relations.filter((r) => r.isActive);
child["schoolId"] = active.map((r) => r.schoolId);
if (active.length > 0) {
child["schoolClass"] = active[0]["schoolClass"];
}
}
private createChildSchoolRelationIndex(): Promise<any> {
const designDoc = {
_id: "_design/childSchoolRelations_index",
views: {
by_child_school: {
map: `(doc) => {
if (!doc._id.startsWith("${ChildSchoolRelation.ENTITY_TYPE}:")) {
return;
};
const start = new Date(doc.start || '3000-01-01').getTime();
emit([doc.childId, start]);
emit([doc.schoolId, start]);
return;
}`,
},
},
};
return this.dbIndexing.createIndex(designDoc);
}
queryRelations(prefix: string) {
const startkey = prefix.endsWith(":") ? [prefix + "\uffff"] : [prefix, {}];
return this.dbIndexing.queryIndexDocs(
ChildSchoolRelation,
"childSchoolRelations_index/by_child_school",
{
startkey,
endkey: [prefix],
descending: true,
},
);
}
queryActiveRelationsOf(
id: string,
date = new Date(),
): Promise<ChildSchoolRelation[]> {
return this.queryRelations(id).then((relations) =>
relations.filter((rel) => rel.isActiveAt(date)),
);
}
/**
* Query all notes that have been linked to the given other entity.
* @param entityId ID (with prefix!) of the related record
*/
async getNotesRelatedTo(entityId: string): Promise<Note[]> {
let legacyLinkedNotes = [];
if (this.inferNoteLinkPropertyFromEntityType(entityId)) {
legacyLinkedNotes = await this.dbIndexing.queryIndexDocs(
Note,
`notes_index/by_${this.inferNoteLinkPropertyFromEntityType(entityId)}`,
entityId,
);
}
const explicitlyLinkedNotes = await this.dbIndexing.queryIndexDocsRange(
Note,
`notes_related_index/note_by_relatedEntities`,
[entityId],
[entityId],
);
return [...legacyLinkedNotes, ...explicitlyLinkedNotes].filter(
// remove duplicates
(element, index, array) =>
array.findIndex((e) => e._id === element._id) === index,
);
}
private inferNoteLinkPropertyFromEntityType(entityId: string): string {
// TODO: rework this to check the entity schema and find the relevant field?
const entityType = Entity.extractTypeFromId(entityId);
switch (entityType) {
case "Child":
return "children";
case "School":
return "schools";
case "User":
return "authors";
}
}
/**
* Query how many days ago the last note for each child was added.
*
* Warning: Children without any notes will be missing from this map.
*
* @param entityType (Optional) entity for which days since last note are calculated. Default "Child".
* @param forLastNDays (Optional) cut-off boundary how many days into the past the analysis will be done.
* @return A map of childIds as key and days since last note as value;
* For performance reasons the days since last note are set to infinity when larger then the forLastNDays parameter
*/
public async getDaysSinceLastNoteOfEachEntity(
entityType = "Child",
forLastNDays: number = 30,
): Promise<Map<string, number>> {
const startDay = moment().subtract(forLastNDays, "days");
const notes = await this.getNotesInTimespan(startDay);
const results = new Map();
const entities = await this.entityMapper.loadType(entityType);
entities
.filter((c) => c.isActive)
.forEach((c) => results.set(c.getId(), Number.POSITIVE_INFINITY));
const noteProperty = Note.getPropertyFor(entityType);
for (const note of notes) {
// TODO: filter notes to only include them if the given child is marked "present"
for (const entityId of note[noteProperty]) {
const daysSinceNote = moment().diff(note.date, "days");
const previousValue = results.get(entityId);
if (previousValue > daysSinceNote) {
results.set(entityId, daysSinceNote);
}
}
}
return results;
}
/**
* Returns all notes in the timespan.
* It is only checked if the notes are on the same day als start and end day. The time is not checked.
* @param startDay the first day where notes should be included
* @param endDay the last day where notes should be included
*/
public async getNotesInTimespan(
startDay: Date | Moment,
endDay: Date | Moment = moment(),
): Promise<Note[]> {
return this.dbIndexing.queryIndexDocsRange(
Note,
"notes_index/note_child_by_date",
moment(startDay).format("YYYY-MM-DD"),
moment(endDay).format("YYYY-MM-DD"),
);
}
private async createNotesIndex(): Promise<any> {
const designDoc = {
_id: "_design/notes_index",
views: {
note_child_by_date: {
map: `(doc) => {
if (!doc._id.startsWith("${Note.ENTITY_TYPE}")) return;
if (!Array.isArray(doc.children) || !doc.date) return;
if (doc.date.length === 10) {
emit(doc.date);
} else {
var d = new Date(doc.date || null);
emit(d.getFullYear() + "-" + String(d.getMonth()+1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0"));
}
}`,
},
},
};
// TODO: remove these and use general note_by_relatedEntities instead --> to be decided later #1501
// creating a by_... view for each of the following properties
["children", "schools", "authors"].forEach(
(prop) =>
(designDoc.views[`by_${prop}`] = this.createNotesByFunction(prop)),
);
await this.dbIndexing.createIndex(designDoc);
const newDesignDoc = {
_id: "_design/notes_related_index",
views: {
note_by_relatedEntities: {
map: `(doc) => {
if (!doc._id.startsWith("${Note.ENTITY_TYPE}")) return;
if (!Array.isArray(doc.relatedEntities)) return;
var dString;
if (doc.date && doc.date.length === 10) {
dString = doc.date;
} else {
var d = new Date(doc.date || null);
dString = d.getFullYear() + "-" + String(d.getMonth()+1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
}
doc.relatedEntities.forEach((relatedEntity) => {
emit([relatedEntity, dString]);
});
}`,
},
},
};
await this.dbIndexing.createIndex(newDesignDoc);
}
private createNotesByFunction(property: string) {
return {
map: `(doc) => {
if (!doc._id.startsWith("${Note.ENTITY_TYPE}")) return;
if (!Array.isArray(doc.${property})) return;
doc.${property}.forEach(val => emit(val));
}`,
};
}
}