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 { 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 { 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 } }> { 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;