40227-vm/backend/src/services/staff_attendance.ts
2026-06-12 14:36:35 +02:00

147 lines
4.0 KiB
TypeScript

import { clampLimit, optionalIsoDate } from '@/services/shared/validate';
import { Op } from 'sequelize';
import db from '@/db/models';
import {
STAFF_ATTENDANCE_DEFAULT_LIMIT,
STAFF_ATTENDANCE_MAX_LIMIT,
STAFF_ATTENDANCE_REPORT_ROLE_NAMES,
STAFF_ATTENDANCE_STATUSES,
} from '@/shared/constants/staff-attendance';
import { STAFF_STATUSES } from '@/shared/constants/staff';
import {
assertAuthenticatedTenantUser,
campusDimensionScope,
hasRoleAccess,
requireOrganizationId,
requireUserId,
} from '@/services/shared/access';
import type { StaffAttendanceRecords } from '@/db/models/staff_attendance_records';
import type { CurrentUser } from '@/db/api/types';
interface StaffAttendanceFilter {
startDate?: unknown;
endDate?: unknown;
limit?: unknown;
}
/**
* Restricts records to the staff member, or (for report roles) to the records
* their scope allows: org-wide for owner/superintendent, the school's campuses
* for principal/registrar, a single campus for director.
*/
function visibilityScope(currentUser?: CurrentUser) {
if (!hasRoleAccess(currentUser, STAFF_ATTENDANCE_REPORT_ROLE_NAMES)) {
return { userId: requireUserId(currentUser) };
}
return campusDimensionScope(currentUser);
}
function dateFilter(filter: StaffAttendanceFilter): {
attendance_date?: { [Op.gte]?: string; [Op.lte]?: string };
} {
const startDate = optionalIsoDate(filter.startDate);
const endDate = optionalIsoDate(filter.endDate);
if (!startDate && !endDate) {
return {};
}
return {
attendance_date: {
...(startDate ? { [Op.gte]: startDate } : {}),
...(endDate ? { [Op.lte]: endDate } : {}),
},
};
}
function toRecordDto(record: StaffAttendanceRecords) {
const plain = record.get({ plain: true });
return {
id: plain.id,
date: plain.attendance_date,
status: plain.status,
note: plain.note,
user_name: plain.user_name,
user_role: plain.user_role,
organizationId: plain.organizationId,
campusId: plain.campusId,
userId: plain.userId,
createdAt: plain.createdAt,
updatedAt: plain.updatedAt,
};
}
class StaffAttendanceService {
static async listRecords(
filter: StaffAttendanceFilter,
currentUser?: CurrentUser,
) {
assertAuthenticatedTenantUser(currentUser);
const result = await db.staff_attendance_records.findAndCountAll({
where: {
organizationId: requireOrganizationId(currentUser),
...visibilityScope(currentUser),
...dateFilter(filter),
},
limit: clampLimit(filter.limit, STAFF_ATTENDANCE_DEFAULT_LIMIT, STAFF_ATTENDANCE_MAX_LIMIT),
order: [
['attendance_date', 'desc'],
['user_name', 'asc'],
],
});
return {
rows: result.rows.map(toRecordDto),
count: result.count,
};
}
static async summary(
filter: StaffAttendanceFilter,
currentUser?: CurrentUser,
) {
assertAuthenticatedTenantUser(currentUser);
// Aggregate in SQL (COUNT per status) instead of fetching every record and
// counting in JS — avoids the limit-truncated, incorrect totals and the
// large row transfer.
const recordsWhere = {
organizationId: requireOrganizationId(currentUser),
...visibilityScope(currentUser),
...dateFilter(filter),
};
const [present, late, absent, staffCount] = await Promise.all([
db.staff_attendance_records.count({
where: { ...recordsWhere, status: STAFF_ATTENDANCE_STATUSES.PRESENT },
}),
db.staff_attendance_records.count({
where: { ...recordsWhere, status: STAFF_ATTENDANCE_STATUSES.LATE },
}),
db.staff_attendance_records.count({
where: { ...recordsWhere, status: STAFF_ATTENDANCE_STATUSES.ABSENT },
}),
db.staff.count({
where: {
organizationId: requireOrganizationId(currentUser),
status: STAFF_STATUSES.ACTIVE,
...campusDimensionScope(currentUser),
},
}),
]);
return {
staffCount,
recordsCount: present + late + absent,
present,
late,
absent,
};
}
}
export default StaffAttendanceService;