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

422 lines
12 KiB
TypeScript

import { clampLimit, requiredIsoDate } from '@/services/shared/validate';
import { Op, literal } from 'sequelize';
import db from '@/db/models';
import { withTransaction } from '@/db/with-transaction';
import ForbiddenError from '@/shared/errors/forbidden';
import ValidationError from '@/shared/errors/validation';
import {
CAMPUS_ATTENDANCE_DEFAULT_LIMIT,
CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES,
CAMPUS_ATTENDANCE_MAX_LIMIT,
normalizeCampusKey,
} from '@/shared/constants/campus-attendance';
import { ROLE_SCOPES } from '@/shared/constants/roles';
import { resolvePagination } from '@/shared/constants/pagination';
import type { CampusAttendanceConfig } from '@/db/models/campus_attendance_config';
import type { CampusAttendanceSummaries } from '@/db/models/campus_attendance_summaries';
import type { CurrentUser } from '@/db/api/types';
import type {
CampusAttendanceFilter,
ConfigInput,
SummaryInput,
} from '@/services/campus_attendance.types';
import {
assertAuthenticatedTenantUser,
getCampusId,
getRoleScope,
getSchoolId,
hasGlobalAccess,
hasRoleAccess,
requireOrganizationId,
requireUserId,
getDisplayName,
} from '@/services/shared/access';
/** UUIDs are inlined into a SQL literal subquery; reject anything that is not one. */
const CA_UUID_RE =
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
function getCurrentUserCampusKey(currentUser?: CurrentUser): string | null {
const staff = currentUser?.staff_user;
const staffProfile = Array.isArray(staff) ? staff[0] : null;
return (
normalizeCampusKey(currentUser?.campus?.code) ||
normalizeCampusKey(currentUser?.campus?.name) ||
normalizeCampusKey(staffProfile?.campus?.code) ||
normalizeCampusKey(staffProfile?.campus?.name) ||
null
);
}
function canManageCampusAttendance(currentUser?: CurrentUser): boolean {
return hasRoleAccess(currentUser, CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES);
}
function assertCanManageCampusAttendance(currentUser?: CurrentUser): void {
assertAuthenticatedTenantUser(currentUser);
if (canManageCampusAttendance(currentUser)) {
return;
}
throw new ForbiddenError();
}
function campusKeyFromRoute(value: unknown): string {
const campusKey = normalizeCampusKey(value);
if (!campusKey) {
throw new ValidationError();
}
return campusKey;
}
/** True for roles whose reach is the whole organization (global + org scope). */
function isOrgWide(currentUser?: CurrentUser): boolean {
return (
hasGlobalAccess(currentUser) ||
getRoleScope(currentUser) === ROLE_SCOPES.ORGANIZATION
);
}
/**
* Subquery of the campus_keys a school owns. `campus_key` is the normalized
* campus code, and the seeded product codes are already lowercase slugs, so
* `LOWER("code")` matches the stored key. Inlines a validated UUID only.
*/
function schoolCampusKeySubquery(schoolId: string) {
if (!CA_UUID_RE.test(schoolId)) {
throw new ForbiddenError();
}
return literal(
`(SELECT LOWER("code") FROM "campuses" WHERE "schoolId" = '${schoolId}' AND "deletedAt" IS NULL)`,
);
}
/** Whether `campusKey` belongs to a campus in the user's school (parameterized). */
async function isCampusKeyInSchool(
campusKey: string,
schoolId: string,
organizationId: string,
): Promise<boolean> {
const count = await db.campuses.count({
where: {
schoolId,
organizationId,
[Op.and]: [
db.sequelize.where(
db.sequelize.fn('LOWER', db.sequelize.col('code')),
campusKey,
),
],
},
});
return count > 0;
}
/** Asserts the user may read/write the given campus_key, by scope tier. */
async function assertCanAccessCampusKey(
campusKey: string,
currentUser?: CurrentUser,
): Promise<void> {
if (isOrgWide(currentUser)) {
return;
}
if (getRoleScope(currentUser) === ROLE_SCOPES.SCHOOL) {
const schoolId = getSchoolId(currentUser);
if (
schoolId &&
(await isCampusKeyInSchool(
campusKey,
schoolId,
requireOrganizationId(currentUser),
))
) {
return;
}
throw new ForbiddenError();
}
const currentCampusKey = getCurrentUserCampusKey(currentUser);
if (currentCampusKey && currentCampusKey === campusKey) {
return;
}
throw new ForbiddenError();
}
/** Resolves the campus_key scope by tier, asserting access along the way. */
async function campusScope(
filter: CampusAttendanceFilter,
currentUser?: CurrentUser,
): Promise<{ campus_key?: string | { [Op.in]: ReturnType<typeof literal> } }> {
const requestedCampusKey = normalizeCampusKey(filter?.campusKey);
if (requestedCampusKey) {
await assertCanAccessCampusKey(requestedCampusKey, currentUser);
return { campus_key: requestedCampusKey };
}
if (isOrgWide(currentUser)) {
return {};
}
// School roles (Principal/Registrar): every campus in their school.
if (getRoleScope(currentUser) === ROLE_SCOPES.SCHOOL) {
const schoolId = getSchoolId(currentUser);
if (!schoolId) {
throw new ForbiddenError();
}
return { campus_key: { [Op.in]: schoolCampusKeySubquery(schoolId) } };
}
const currentCampusKey = getCurrentUserCampusKey(currentUser);
if (!currentCampusKey) {
throw new ForbiddenError();
}
return { campus_key: currentCampusKey };
}
function dateRange(filter: CampusAttendanceFilter): {
attendance_date?: { [Op.gte]?: string; [Op.lte]?: string };
} {
const start = filter.startDate ? requiredIsoDate(filter.startDate) : null;
const end = filter.endDate ? requiredIsoDate(filter.endDate) : null;
if (!start && !end) {
return {};
}
return {
attendance_date: {
...(start ? { [Op.gte]: start } : {}),
...(end ? { [Op.lte]: end } : {}),
},
};
}
function requiredNonNegativeInteger(value: unknown): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
throw new ValidationError();
}
return value;
}
function optionalText(value: unknown): string | null {
if (typeof value !== 'string' || value.trim().length === 0) {
return null;
}
return value.trim();
}
function validateSummary(data: SummaryInput) {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new ValidationError();
}
const totalEnrolled = requiredNonNegativeInteger(data.total_enrolled);
const totalPresent = requiredNonNegativeInteger(data.total_present);
const totalAbsent = requiredNonNegativeInteger(data.total_absent);
const totalTardy = requiredNonNegativeInteger(data.total_tardy ?? 0);
if (
totalEnrolled <= 0 ||
totalPresent > totalEnrolled ||
totalAbsent > totalEnrolled ||
totalTardy > totalEnrolled
) {
throw new ValidationError();
}
return {
total_enrolled: totalEnrolled,
total_present: totalPresent,
total_absent: totalAbsent,
total_tardy: totalTardy,
attendance_percentage: ((totalPresent / totalEnrolled) * 100).toFixed(2),
notes: optionalText(data.notes),
};
}
function toConfigDto(record: CampusAttendanceConfig) {
const plain = record.get({ plain: true });
return {
id: plain.id,
campus_key: plain.campus_key,
attendance_link: plain.attendance_link,
updated_by_label: plain.updated_by_label,
organizationId: plain.organizationId,
campusId: plain.campusId,
createdById: plain.createdById,
updatedById: plain.updatedById,
createdAt: plain.createdAt,
updatedAt: plain.updatedAt,
};
}
function toSummaryDto(record: CampusAttendanceSummaries) {
const plain = record.get({ plain: true });
return {
id: plain.id,
campus_key: plain.campus_key,
date: plain.attendance_date,
total_enrolled: plain.total_enrolled,
total_present: plain.total_present,
total_absent: plain.total_absent,
total_tardy: plain.total_tardy,
attendance_percentage: Number(plain.attendance_percentage),
recorded_by_label: plain.recorded_by_label,
notes: plain.notes,
organizationId: plain.organizationId,
campusId: plain.campusId,
createdById: plain.createdById,
updatedById: plain.updatedById,
createdAt: plain.createdAt,
updatedAt: plain.updatedAt,
};
}
class CampusAttendanceService {
static async listConfigs(
filter: CampusAttendanceFilter,
currentUser?: CurrentUser,
) {
assertAuthenticatedTenantUser(currentUser);
const { limit, offset } = resolvePagination(filter.limit, filter.page);
const result = await db.campus_attendance_config.findAndCountAll({
where: {
organizationId: requireOrganizationId(currentUser),
...(await campusScope(filter, currentUser)),
},
order: [['campus_key', 'asc']],
limit,
offset,
});
return {
rows: result.rows.map(toConfigDto),
count: result.count,
};
}
static async upsertConfig(
campusKeyParam: unknown,
data: ConfigInput,
currentUser?: CurrentUser,
) {
assertCanManageCampusAttendance(currentUser);
const campusKey = campusKeyFromRoute(campusKeyParam);
await assertCanAccessCampusKey(campusKey, currentUser);
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new ValidationError();
}
const payload = {
campus_key: campusKey,
attendance_link: optionalText(data.attendance_link),
updated_by_label: getDisplayName(currentUser),
organizationId: requireOrganizationId(currentUser),
campusId: getCampusId(currentUser),
updatedById: currentUser?.id ?? null,
};
return withTransaction(async (transaction) => {
const existing = await db.campus_attendance_config.findOne({
where: {
organizationId: requireOrganizationId(currentUser),
campus_key: campusKey,
},
transaction,
});
const saved = existing
? await existing.update(payload, { transaction })
: await db.campus_attendance_config.create(
{ ...payload, createdById: requireUserId(currentUser) },
{ transaction },
);
return toConfigDto(saved);
});
}
static async listSummaries(
filter: CampusAttendanceFilter,
currentUser?: CurrentUser,
) {
assertAuthenticatedTenantUser(currentUser);
const result = await db.campus_attendance_summaries.findAndCountAll({
where: {
organizationId: requireOrganizationId(currentUser),
...(await campusScope(filter, currentUser)),
...dateRange(filter),
},
limit: clampLimit(filter.limit, CAMPUS_ATTENDANCE_DEFAULT_LIMIT, CAMPUS_ATTENDANCE_MAX_LIMIT),
order: [
['attendance_date', 'desc'],
['campus_key', 'asc'],
],
});
return {
rows: result.rows.map(toSummaryDto),
count: result.count,
};
}
static async upsertSummary(
campusKeyParam: unknown,
dateParam: unknown,
data: SummaryInput,
currentUser?: CurrentUser,
) {
assertCanManageCampusAttendance(currentUser);
const campusKey = campusKeyFromRoute(campusKeyParam);
const attendanceDate = requiredIsoDate(dateParam);
await assertCanAccessCampusKey(campusKey, currentUser);
const validatedSummary = validateSummary(data);
const payload = {
...validatedSummary,
campus_key: campusKey,
attendance_date: attendanceDate,
recorded_by_label: getDisplayName(currentUser),
organizationId: requireOrganizationId(currentUser),
campusId: getCampusId(currentUser),
updatedById: currentUser?.id ?? null,
};
return withTransaction(async (transaction) => {
const existing = await db.campus_attendance_summaries.findOne({
where: {
organizationId: requireOrganizationId(currentUser),
campus_key: campusKey,
attendance_date: attendanceDate,
},
transaction,
});
const saved = existing
? await existing.update(payload, { transaction })
: await db.campus_attendance_summaries.create(
{ ...payload, createdById: requireUserId(currentUser) },
{ transaction },
);
return toSummaryDto(saved);
});
}
}
export default CampusAttendanceService;