File

src/app/features/attendance/attendance.service.ts

Index

Properties
Methods

Constructor

constructor()

Methods

Async createEventForActivity
createEventForActivity(activity: Entity | string, date: Date)
Parameters :
Name Type Optional
activity Entity | string No
date Date No
Async getActivitiesForParticipant
getActivitiesForParticipant(participantId: string)

Load all activities that list the given participant ID in their participants field. Queries all configured recurring activity types (see AttendanceFeatureConfig.recurringActivityTypes).

When the legacy RecurringActivity/Event + schools→linkedGroups mapping is detected, also includes activities linked via school group membership (legacy).

Parameters :
Name Type Optional Description
participantId string No

The entity ID of the participant to look up.

Returns : Promise<Entity[]>
Async getActivityAttendances
getActivityAttendances(activity: Entity, from?: Date)

Load and calculate activity attendance records grouped by month.

Parameters :
Name Type Optional Description
activity Entity No

The activity for which records are loaded.

from Date Yes

(Optional) date starting from which events should be considered.

Async getAvailableEventsForRollCall
getAvailableEventsForRollCall(date: Date)

Load all events available for a roll call on the given date, merging existing events with new (unsaved) events generated from recurring activities.

Returns two lists: events contains only events relevant to the current user (assigned activities), while allEvents contains everything.

Parameters :
Name Type Optional Description
date Date No

The date for which to load events.

Returns : Promise<literal type>
Async getEventsOnDate
getEventsOnDate(startDate: Date, endDate: Date)

Return all events on the given date or date range.

Parameters :
Name Type Optional Default value Description
startDate Date No

The date (or start date of a range)

endDate Date No startDate

(Optional) end date of the period to be queried; if not given, defaults to the start date

Returns : Promise<Entity[]>
wrapEventEntity
wrapEventEntity(entity: Entity)

Wrap an event entity with typed attendance/date/relatesTo/authors accessors based on the configured field names for its event type.

Parameters :
Name Type Optional
entity Entity No

Properties

Readonly activityTypes
Type : unknown
Default value : signal<EntityConstructor[]>([])

Unique activity-type constructors derived from the current config.

Static Readonly CONFIG_KEY
Type : string
Default value : "appConfig:attendance"
Readonly eventTypes
Type : unknown
Default value : signal<EntityConstructor[]>([])

Unique event-type constructors derived from the current config.

eventTypeSettings
Type : EventTypeSettings[]
Default value : []

Full settings for each configured event type.

Readonly filterConfig
Type : unknown
Default value : signal<FilterConfig[]>([])

Merged filter config from all event type entries.

import { Injectable, inject, signal } from "@angular/core";
import { EntityMapperService } from "#src/app/core/entity/entity-mapper/entity-mapper.service";
import { Entity, EntityConstructor } from "#src/app/core/entity/model/entity";
import moment from "moment";
import { ActivityAttendance } from "./model/activity-attendance";
import { DatabaseIndexingService } from "#src/app/core/entity/database-indexing/database-indexing.service";
import { AttendanceItem } from "./model/attendance-item";
import { CurrentUserSubject } from "#src/app/core/session/current-user-subject";
import {
  EventTypeSettings,
  AttendanceFeatureConfig,
} from "./model/attendance-feature-config";
import { FilterConfig } from "#src/app/core/entity-list/EntityListConfig";
import { EventWithAttendance } from "./model/event-with-attendance";
import { ConfigService } from "#src/app/core/config/config.service";
import { EntityRegistry } from "#src/app/core/entity/database-entity.decorator";
import { GroupParticipantResolverService } from "./deprecated/group-participant-resolver";
import { AttendanceDatatype } from "./model/attendance.datatype";
import { DateDatatype } from "#src/app/core/basic-datatypes/date/date.datatype";
import { Logging } from "#src/app/core/logging/logging.service";
import { extractParticipantIds } from "./model/participant-id-extractor";

@Injectable({
  providedIn: "root",
})
export class AttendanceService {
  static readonly CONFIG_KEY = "appConfig:attendance";

  private readonly entityMapper = inject(EntityMapperService);
  private readonly dbIndexing = inject(DatabaseIndexingService);
  private readonly currentUser = inject(CurrentUserSubject);
  private readonly configService = inject(ConfigService);
  private readonly entityRegistry = inject(EntityRegistry);
  private readonly groupParticipantResolver = inject(
    GroupParticipantResolverService,
  );

  /** Full settings for each configured event type. */
  eventTypeSettings: EventTypeSettings[] = [];
  /** Whether legacy group-based participant resolution is active. */
  private groupBasedParticipants = false;

  /** Unique activity-type constructors derived from the current config. */
  readonly activityTypes = signal<EntityConstructor[]>([]);
  /** Unique event-type constructors derived from the current config. */
  readonly eventTypes = signal<EntityConstructor[]>([]);
  /** Merged filter config from all event type entries. */
  readonly filterConfig = signal<FilterConfig[]>([]);

  constructor() {
    this.applyConfig(undefined);
    this.configService.configUpdates.subscribe((config) => {
      this.applyConfig(config?.data?.[AttendanceService.CONFIG_KEY]);
      this.createIndices();
    });
  }

  private applyConfig(raw?: AttendanceFeatureConfig): void {
    const eventTypesConfig = raw?.eventTypes ?? [];

    this.eventTypeSettings = eventTypesConfig
      .map((typeConfig) => {
        const eventTypeName = typeConfig.eventType;
        if (!this.entityRegistry.has(eventTypeName)) return null;

        const activityTypeName = typeConfig.activityType;
        if (activityTypeName && !this.entityRegistry.has(activityTypeName))
          return null;

        const eventType = this.entityRegistry.get(eventTypeName);

        const resolvedDateField =
          typeConfig.dateField ?? DateDatatype.detectFieldInEntity(eventType);
        if (!resolvedDateField) {
          Logging.warn(
            `[AttendanceService] No date field found for event type "${eventTypeName}". ` +
              `Set "dateField" in the attendance config or add a @DatabaseField with dataType "date" to the entity.`,
          );
        }

        return {
          activityType: activityTypeName
            ? this.entityRegistry.get(activityTypeName)
            : undefined,
          eventType,
          participantsField: typeConfig.participantsField ?? "participants",
          attendanceField:
            typeConfig.attendanceField ??
            AttendanceDatatype.detectFieldInEntity(eventType),
          dateField: resolvedDateField,
          relatesToField: typeConfig.relatesToField ?? "relatesTo",
          eventAssignedUsersField: typeConfig.eventAssignedUsersField,
          activityAssignedUsersField: typeConfig.activityAssignedUsersField,
          filterConfig: typeConfig.filterConfig ?? [],
          extraField: typeConfig.extraField,
          fieldMapping: typeConfig.fieldMapping ?? {},
        } as EventTypeSettings;
      })
      .filter((s): s is EventTypeSettings => s !== null);

    this.activityTypes.set([
      ...new Set(
        this.eventTypeSettings
          .filter((s) => s.activityType !== undefined)
          .map((s) => s.activityType!),
      ),
    ]);
    this.eventTypes.set([
      ...new Set(this.eventTypeSettings.map((s) => s.eventType)),
    ]);

    const filterConfigMap = new Map();
    for (const ts of this.eventTypeSettings) {
      for (const f of ts.filterConfig) {
        filterConfigMap.set(f.id, f);
      }
    }
    this.filterConfig.set(Array.from(filterConfigMap.values()));

    this.groupBasedParticipants = this.eventTypeSettings.some(
      (s) =>
        s.activityType?.ENTITY_TYPE === "RecurringActivity" &&
        s.eventType.ENTITY_TYPE === "EventNote" &&
        s.fieldMapping["schools"] === "linkedGroups",
    );
  }

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

  private createEventsIndex(): Promise<void> {
    if (this.eventTypes().length === 0) {
      return Promise.resolve();
    }

    const eventTypeChecks = this.eventTypes()
      .map((t) => `doc._id.startsWith("${t.ENTITY_TYPE}:")`)
      .join(" || ");

    const byActivityEmits = this.eventTypeSettings
      .filter((s) => s.activityType !== undefined)
      .map(
        ({ eventType, relatesToField }) =>
          `      if (doc._id.startsWith("${eventType.ENTITY_TYPE}:") && doc["${relatesToField}"]) {
        emit(doc["${relatesToField}"] + "_" + dString);
      }`,
      )
      .join("\n");

    const designDoc = {
      _id: "_design/events_index",
      views: {
        by_date: {
          map: `(doc) => {
            if (${eventTypeChecks}) {
              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);
              }
            }
          }`,
        },
        by_activity: {
          map: `(doc) => {
            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");
            }
${byActivityEmits}
          }`,
        },
      },
    };

    return this.dbIndexing.createIndex(designDoc);
  }

  private createRecurringActivitiesIndex(): Promise<void> {
    const activitySettings = this.eventTypeSettings.filter(
      (s) => s.activityType !== undefined,
    );
    if (activitySettings.length === 0) {
      return Promise.resolve();
    }

    const byParticipantChecks = activitySettings
      .map(
        ({ activityType, participantsField }) =>
          `      if (doc._id.startsWith("${activityType!.ENTITY_TYPE}:")) {
        for (var p of (doc["${participantsField}"] || [])) {
          if (typeof p === "string") {
            emit(p);
          } else if (p && typeof p === "object" && typeof p.participant === "string") {
            emit(p.participant);
          }
        }
      }`,
      )
      .join("\n");

    const designDoc = {
      _id: "_design/activities_index",
      views: {
        by_participant: {
          map: `(doc) => {
${byParticipantChecks}
          }`,
        },
      },
    };

    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<Entity[]> {
    const start = moment(startDate);
    const end = moment(endDate);

    const eventQueries = this.eventTypes().map((eventType) =>
      this.dbIndexing.queryIndexDocsRange(
        eventType,
        "events_index/by_date",
        start.format("YYYY-MM-DD"),
        end.format("YYYY-MM-DD"),
      ),
    );

    const allResults = await Promise.all(eventQueries);
    return ([] as Entity[]).concat(...allResults);
  }

  /**
   * Load events related to the given 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.
   */
  private async getEventsForActivity(
    activityId: string,
    sinceDate?: Date,
  ): Promise<Entity[]> {
    let dateLimit = "";
    if (sinceDate) {
      dateLimit =
        "_" +
        sinceDate.getFullYear() +
        "-" +
        String(sinceDate.getMonth() + 1).padStart(2, "0") +
        "-" +
        String(sinceDate.getDate()).padStart(2, "0");
    }

    const eventQueries = this.eventTypes().map((eventType) =>
      this.dbIndexing.queryIndexDocsRange(
        eventType,
        "events_index/by_activity",
        activityId + dateLimit,
        activityId,
      ),
    );

    const results = await Promise.all(eventQueries);
    return ([] as Entity[]).concat(...results);
  }

  /**
   * Load and calculate activity attendance records grouped by month.
   * @param activity The activity for which records are loaded.
   * @param from (Optional) date starting from which events should be considered.
   */
  async getActivityAttendances(
    activity: Entity,
    from?: Date,
  ): Promise<ActivityAttendance[]> {
    const periods = new Map<number, ActivityAttendance>();

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

    const getOrCreateAttendancePeriod = (event: EventWithAttendance) => {
      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;
    };

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

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

  /**
   * Load all activities that list the given participant ID in their `participants` field.
   * Queries all configured recurring activity types (see {@link AttendanceFeatureConfig.recurringActivityTypes}).
   *
   * When the legacy RecurringActivity/Event + schools→linkedGroups mapping is detected,
   * also includes activities linked via school group membership (legacy).
   *
   * @param participantId The entity ID of the participant to look up.
   */
  async getActivitiesForParticipant(participantId: string): Promise<Entity[]> {
    const directActivities = await Promise.all(
      this.activityTypes().map((activityType) =>
        this.dbIndexing.queryIndexDocs(
          activityType,
          "activities_index/by_participant",
          participantId,
        ),
      ),
    );
    const results = ([] as Entity[]).concat(...directActivities);

    if (!this.groupBasedParticipants) {
      return results;
    }

    const groupActivities =
      await this.groupParticipantResolver.getActivitiesForParticipantViaGroups(
        participantId,
        this.activityTypes(),
      );

    const merged = new Map<string, Entity>();
    for (const a of [...results, ...groupActivities]) {
      merged.set(a.getId(), a);
    }
    return Array.from(merged.values());
  }

  /**
   * Load all events available for a roll call on the given date,
   * merging existing events with new (unsaved) events generated from recurring activities.
   *
   * Returns two lists: `events` contains only events relevant to the current user
   * (assigned activities), while `allEvents` contains everything.
   *
   * @param date The date for which to load events.
   */
  async getAvailableEventsForRollCall(date: Date): Promise<{
    events: EventWithAttendance[];
    allEvents: EventWithAttendance[];
  }> {
    const currentUserId = this.currentUser.value?.getId();
    const existingEvents = await this.getEventsOnDate(date, date);

    const allActivitiesNested = await Promise.all(
      this.eventTypeSettings
        .filter((s) => s.activityType !== undefined)
        .map((typeSettings) =>
          this.entityMapper.loadType(typeSettings.activityType!),
        ),
    );
    const allActivities = ([] as Entity[])
      .concat(...allActivitiesNested)
      .filter((a) => a.isActive);

    const allEvents = await this.buildEventsFromActivities(
      allActivities,
      existingEvents,
      date,
    );

    const assignedActivityIds = allActivities
      .filter((a) => this.getActivityAssignedUsers(a)?.includes(currentUserId))
      .map((a) => a.getId());

    const filteredEvents = !currentUserId
      ? allEvents
      : allEvents.filter(
          (e) => !e.activityId || assignedActivityIds.includes(e.activityId),
        );

    return {
      events: filteredEvents,
      allEvents: allEvents,
    };
  }

  /**
   * Wrap an event entity with typed attendance/date/relatesTo/authors accessors
   * based on the configured field names for its event type.
   */
  wrapEventEntity(entity: Entity): EventWithAttendance {
    const typeSettings = this.eventTypeSettings.find(
      (s) => s.eventType.ENTITY_TYPE === entity.getType(),
    );
    if (!typeSettings) {
      throw new Error(
        `No attendance event config found for "${entity.getType()}"`,
      );
    }

    return new EventWithAttendance(
      entity,
      typeSettings.attendanceField,
      typeSettings.dateField,
      typeSettings.relatesToField,
      typeSettings.eventAssignedUsersField,
      typeSettings.extraField,
    );
  }

  private async buildEventsFromActivities(
    activities: Entity[],
    existingEvents: Entity[],
    date: Date,
  ): Promise<EventWithAttendance[]> {
    const wrappedExisting = existingEvents.map((e) => this.wrapEventEntity(e));

    const newWrappedEvents = await Promise.all(
      activities.map(async (activity) => {
        const typeSettings = this.eventTypeSettings.find(
          (s) =>
            s.activityType !== undefined &&
            s.activityType.ENTITY_TYPE === activity.getType(),
        );
        if (
          existingEvents.find(
            (e) => e[typeSettings?.relatesToField] === activity.getId(),
          )
        ) {
          return undefined;
        }
        return this.createEventForActivity(activity, date);
      }),
    );

    const allEvents = [
      ...wrappedExisting,
      ...newWrappedEvents.filter((e): e is EventWithAttendance => !!e),
    ];

    this.sortEventsByRelevance(allEvents, activities);
    return allEvents;
  }

  private sortEventsByRelevance(
    events: EventWithAttendance[],
    allActivities: Entity[],
  ): void {
    const calculatePriority = (event: EventWithAttendance): number => {
      let score = 0;

      const isActivity = event.isActivityEvent;
      const matchedActivity = isActivity
        ? allActivities.find((a) => a.getId() === event.activityId)
        : undefined;
      const activityAssignedUsers = matchedActivity
        ? this.getActivityAssignedUsers(matchedActivity)
        : undefined;
      // use parent activity's assigned users and only fall back to event if necessary
      const assignedUsers: string[] =
        activityAssignedUsers ?? event.assignedUsers;

      if (!isActivity) {
        // show one-time events first
        score += 1;
      }

      const currentUserId = this.currentUser.value?.getId();
      if (currentUserId && assignedUsers.includes(currentUserId)) {
        score += 2;
      }

      return score;
    };

    events.sort((a, b) => calculatePriority(b) - calculatePriority(a));
  }

  async createEventForActivity(
    activity: Entity | string,
    date: Date,
  ): Promise<EventWithAttendance> {
    if (typeof activity === "string") {
      const activityTypeName = activity.split(":")[0];
      const typeSettings = this.eventTypeSettings.find(
        (s) =>
          s.activityType !== undefined &&
          s.activityType.ENTITY_TYPE === activityTypeName,
      );
      if (!typeSettings) {
        throw new Error(
          `No config found for activity type "${activityTypeName}"`,
        );
      }
      activity = await this.entityMapper.load(
        typeSettings.activityType!,
        activity,
      );
    }

    const typeSettings = this.eventTypeSettings.find(
      (s) =>
        s.activityType !== undefined &&
        s.activityType.ENTITY_TYPE === activity.getType(),
    );
    if (!typeSettings) {
      throw new Error(`No config found for activity "${activity.getId()}"`);
    }

    const instance = new typeSettings.eventType();

    // Set date
    if (typeSettings.dateField) {
      instance[typeSettings.dateField] = date;
    }

    // Apply field mapping (activity[actField] → event[evField])
    for (const [evField, actField] of Object.entries(
      typeSettings.fieldMapping,
    )) {
      const value = activity[actField];
      instance[evField] =
        typeof value === "object" && value !== null
          ? structuredClone(value)
          : value;
    }

    // Resolve participants
    let participantIds: string[];
    if (
      this.groupBasedParticipants &&
      activity.getType() === "RecurringActivity"
    ) {
      participantIds =
        await this.groupParticipantResolver.getActiveParticipantsOfActivity(
          activity,
          date,
        );
    } else {
      participantIds = extractParticipantIds(
        activity[typeSettings.participantsField],
      );
    }

    // Set attendance items
    instance[typeSettings.attendanceField] = participantIds.map(
      (id) => new AttendanceItem(undefined, "", id),
    );

    // Set relatesTo
    instance[typeSettings.relatesToField] = activity.getId();

    // Set authors
    if (this.currentUser.value) {
      instance[typeSettings.eventAssignedUsersField] = [
        this.currentUser.value.getId(),
      ];
    }

    return new EventWithAttendance(
      instance,
      typeSettings.attendanceField,
      typeSettings.dateField,
      typeSettings.relatesToField,
      typeSettings.eventAssignedUsersField,
      typeSettings.extraField,
    );
  }

  /**
   * Get the assigned user IDs from an activity entity using the configured
   * `activityAssignedUsersField`. Returns `undefined` if no field is configured
   * or the field is not an array on the entity.
   */
  private getActivityAssignedUsers(activity: Entity): string[] | undefined {
    const actType = activity.getType();
    for (const s of this.eventTypeSettings) {
      if (s.activityType?.ENTITY_TYPE === actType) {
        if (!s.activityAssignedUsersField) continue;
        const val = activity[s.activityAssignedUsersField];
        if (Array.isArray(val)) return val;
      }
    }
    return undefined;
  }
}

results matching ""

    No results matching ""