src/app/features/attendance/attendance.service.ts
Properties |
|
Methods |
|
constructor()
|
| Async createEventForActivity | |||||||||
createEventForActivity(activity: Entity | string, date: Date)
|
|||||||||
|
Parameters :
Returns :
Promise<EventWithAttendance>
|
| Async getActivitiesForParticipant | ||||||||
getActivitiesForParticipant(participantId: string)
|
||||||||
|
Load all activities that list the given participant ID in their When the legacy RecurringActivity/Event + schools→linkedGroups mapping is detected, also includes activities linked via school group membership (legacy).
Parameters :
Returns :
Promise<Entity[]>
|
| Async getActivityAttendances | ||||||||||||
getActivityAttendances(activity: Entity, from?: Date)
|
||||||||||||
|
Load and calculate activity attendance records grouped by month.
Parameters :
Returns :
Promise<ActivityAttendance[]>
|
| 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:
Parameters :
Returns :
Promise<literal type>
|
| Async getEventsOnDate | |||||||||||||||
getEventsOnDate(startDate: Date, endDate: Date)
|
|||||||||||||||
|
Return all events on the given date or date range.
Parameters :
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 :
Returns :
EventWithAttendance
|
| 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;
}
}