NGO-DB/ndb-core

View on GitHub
src/app/child-dev-project/attendance/attendance.service.ts

Summary

Maintainability
A
3 hrs
Test Coverage
import { Injectable } from "@angular/core";
import { EntityMapperService } from "../../core/entity/entity-mapper/entity-mapper.service";
import moment from "moment";
import { RecurringActivity } from "./model/recurring-activity";
import { ActivityAttendance } from "./model/activity-attendance";
import { groupBy } from "../../utils/utils";
import { DatabaseIndexingService } from "../../core/entity/database-indexing/database-indexing.service";
import { EventNote } from "./model/event-note";
import { ChildrenService } from "../children/children.service";

@Injectable({
  providedIn: "root",
})
export class AttendanceService {
  constructor(
    private entityMapper: EntityMapperService,
    private dbIndexing: DatabaseIndexingService,
    private childrenService: ChildrenService,
  ) {
    this.createIndices();
  }

  private createIndices() {
    this.createEventsIndex();
    this.createRecurringActivitiesIndex();
  }

  private createEventsIndex(): Promise<void> {
    const designDoc = {
      _id: "_design/events_index",
      views: {
        by_date: {
          map: `(doc) => {
            if (doc._id.startsWith("${EventNote.ENTITY_TYPE}")) {
              if (doc.date && doc.date.length === 10) {
                emit(doc.date);
              } else {
                var d = new Date(doc.date || null);
                var dString = d.getFullYear() + "-" + String(d.getMonth()+1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
                emit(dString);
              }
            }
          }`,
        },
        // TODO: remove this and use general Note's relatedEntities index?
        by_activity: {
          map: `(doc) => {
            if (doc._id.startsWith("${EventNote.ENTITY_TYPE}") && doc.relatesTo) {
              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");
              }
              emit(doc.relatesTo + "_" + dString);
            }
          }`,
        },
      },
    };

    return this.dbIndexing.createIndex(designDoc);
  }

  private createRecurringActivitiesIndex(): Promise<void> {
    const designDoc = {
      _id: "_design/activities_index",
      views: {
        by_participant: {
          map: `(doc) => {
            if (doc._id.startsWith("${RecurringActivity.ENTITY_TYPE}")) {
              for (var p of (doc.participants || [])) {
                emit(p);
              }
            }
          }`,
        },
        by_school: {
          map: `(doc) => {
            if (doc._id.startsWith("${RecurringActivity.ENTITY_TYPE}")) {
              for (var g of (doc.linkedGroups || [])) {
                emit(g);
              }
            }
          }`,
        },
      },
    };

    return this.dbIndexing.createIndex(designDoc);
  }

  /**
   * Return all events on the given date or date range.
   * @param startDate The date (or start date of a range)
   * @param endDate (Optional) end date of the period to be queried; if not given, defaults to the start date
   */
  async getEventsOnDate(
    startDate: Date,
    endDate: Date = startDate,
  ): Promise<EventNote[]> {
    const start = moment(startDate);
    const end = moment(endDate);

    const eventNotes = this.dbIndexing.queryIndexDocsRange(
      EventNote,
      "events_index/by_date",
      start.format("YYYY-MM-DD"),
      end.format("YYYY-MM-DD"),
    );

    const relevantNormalNotes = this.childrenService
      .getNotesInTimespan(start, end)
      .then((notes) => notes.filter((n) => n.category?.isMeeting));

    const allResults = await Promise.all([eventNotes, relevantNormalNotes]);
    return allResults[0].concat(allResults[1]);
  }

  async getEventsWithUpdatedParticipants(date: Date) {
    const events = await this.getEventsOnDate(date, date);
    for (const event of events) {
      const participants = await this.loadParticipantsOfGroups(
        event.schools,
        date,
      );
      for (const newParticipant of participants) {
        event.addChild(newParticipant);
      }
    }
    return events;
  }

  /**
   * Load events related to the given recurring activity.
   * @param activityId The reference activity the events should relate to.
   * @param sinceDate (Optional) date starting from which events should be considered. Events before this are ignored to improve performance.
   */
  async getEventsForActivity(
    activityId: string,
    sinceDate?: Date,
  ): Promise<EventNote[]> {
    if (!activityId.startsWith(RecurringActivity.ENTITY_TYPE)) {
      activityId = RecurringActivity.ENTITY_TYPE + ":" + activityId;
    }

    let dateLimit = "";
    if (sinceDate) {
      dateLimit =
        "_" +
        sinceDate.getFullYear() +
        "-" +
        String(sinceDate.getMonth() + 1).padStart(2, "0") +
        "-" +
        String(sinceDate.getDate()).padStart(2, "0");
    }

    return this.dbIndexing.queryIndexDocsRange(
      EventNote,
      "events_index/by_activity",
      activityId + dateLimit,
      activityId,
    );
  }

  /**
   * Load and calculate activity attendance records.
   * @param activity To activity for which records are loaded.
   * @param sinceDate (Optional) date starting from which events should be considered. Events before this are ignored to improve performance.
   */
  async getActivityAttendances(
    activity: RecurringActivity,
    sinceDate?: Date,
  ): Promise<ActivityAttendance[]> {
    const periods = new Map<number, ActivityAttendance>();

    function getOrCreateAttendancePeriod(event) {
      const month = new Date(event.date.getFullYear(), event.date.getMonth());
      let attMonth = periods.get(month.getTime());
      if (!attMonth) {
        attMonth = ActivityAttendance.create(month);
        attMonth.periodTo = moment(month).endOf("month").toDate();
        attMonth.activity = activity;
        periods.set(month.getTime(), attMonth);
      }
      return attMonth;
    }

    const events = await this.getEventsForActivity(activity.getId(), sinceDate);

    for (const event of events) {
      const record = getOrCreateAttendancePeriod(event);
      record.events.push(event);
    }

    return Array.from(periods.values()).sort(
      (a, b) => a.periodFrom.getTime() - b.periodFrom.getTime(),
    );
  }

  async getAllActivityAttendancesForPeriod(
    from: Date,
    until: Date,
  ): Promise<ActivityAttendance[]> {
    const matchingEvents = await this.getEventsOnDate(from, until);
    const groupedEvents = groupBy(matchingEvents, "relatesTo");

    const records = [];
    for (const [activityId, activityEvents] of groupedEvents) {
      const activityRecord = ActivityAttendance.create(from, activityEvents);
      activityRecord.periodTo = until;
      if (activityId) {
        activityRecord.activity = await this.entityMapper
          .load<RecurringActivity>(RecurringActivity, activityId)
          .catch(() => undefined);
      }

      records.push(activityRecord);
    }

    return records;
  }

  async getActivitiesForChild(childId: string): Promise<RecurringActivity[]> {
    const activities = await this.dbIndexing.queryIndexDocs(
      RecurringActivity,
      "activities_index/by_participant",
      childId,
    );

    const visitedSchools =
      await this.childrenService.queryActiveRelationsOf(childId);
    for (const currentRelation of visitedSchools) {
      const activitiesThroughRelation = await this.dbIndexing.queryIndexDocs(
        RecurringActivity,
        "activities_index/by_school",
        currentRelation.schoolId,
      );
      for (const activityThroughRelation of activitiesThroughRelation) {
        if (
          !activities.some((a) => a.getId() === activityThroughRelation.getId())
        ) {
          activities.push(activityThroughRelation);
        }
      }
    }

    return activities;
  }

  async createEventForActivity(
    activity: RecurringActivity,
    date: Date,
  ): Promise<EventNote> {
    const instance = new EventNote();
    instance.date = date;
    instance.subject = activity.title;
    instance.children = await this.getActiveParticipantsOfActivity(
      activity,
      date,
    );
    instance.schools = activity.linkedGroups;
    instance.relatesTo = activity.getId();
    instance.category = activity.type;
    return instance;
  }

  private async getActiveParticipantsOfActivity(
    activity: RecurringActivity,
    date: Date,
  ): Promise<string[]> {
    const schoolParticipants = await this.loadParticipantsOfGroups(
      activity.linkedGroups,
      date,
    );

    return [
      ...new Set(activity.participants.concat(...schoolParticipants)), //  remove duplicates
    ].filter((p) => !activity.excludedParticipants.includes(p));
  }

  /**
   * Load all participants' ids for the given list of groups
   * @param linkedGroups
   * @param date on which the participants should be part of the group
   */
  private async loadParticipantsOfGroups(
    linkedGroups: string[],
    date: Date,
  ): Promise<string[]> {
    const childIdPromises = linkedGroups.map((groupId) =>
      this.childrenService
        .queryActiveRelationsOf(groupId, date)
        .then((relations) =>
          relations.map((r) => r.childId).filter((id) => !!id),
        ),
    );
    const allParticipants = await Promise.all(childIdPromises);
    // flatten and remove duplicates
    return Array.from(new Set([].concat(...allParticipants)));
  }
}