422 lines
12 KiB
TypeScript
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;
|