40227-vm/backend/src/services/campus_attendance.js
Dmitri d4a5378adf Refactor: migrate frontend to Vite/React, add product backend modules
Frontend:
- Replace Next.js with Vite + React + TypeScript
- Add new component architecture (app-shell, sidebar, dashboard modules)
- Implement product modules: FRAME, safety protocols, walkthrough checkin,
  campus/staff attendance, personality quiz, sign language, classroom timer
- Add shadcn/ui component library with Tailwind CSS
- Remove legacy generated components, stores, and pages

Backend:
- Add product migrations: frame_entries, user_progress, safety_quiz_results,
  walkthrough_checkins, communication_events, personality_quiz_results,
  campus_attendance_config/summaries, staff_attendance_records, content_catalog
- Add corresponding models, services, and routes
- Implement cookie-based auth with refresh token rotation
- Add content catalog seeder with product content
- Migrate to ESLint flat config
- Switch from yarn to npm

Infrastructure:
- Update .gitignore for new tooling
- Add project documentation (CLAUDE.md, docs/)
- Remove deprecated config files and yarn.lock

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-09 15:18:23 +02:00

375 lines
10 KiB
JavaScript

const db = require('../db/models');
const ForbiddenError = require('./notifications/errors/forbidden');
const ValidationError = require('./notifications/errors/validation');
const {
CAMPUS_ATTENDANCE_DEFAULT_LIMIT,
CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES,
CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES,
CAMPUS_ATTENDANCE_MAX_LIMIT,
CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES,
getProductRole,
normalizeCampusKey,
} = require('../constants/campus-attendance');
function getOrganizationId(currentUser) {
return currentUser?.organizations?.id
|| currentUser?.organization?.id
|| currentUser?.organizationsId
|| currentUser?.organizationId
|| null;
}
function getCampusId(currentUser) {
if (Array.isArray(currentUser?.staff_user) && currentUser.staff_user[0]?.campusId) {
return currentUser.staff_user[0].campusId;
}
return currentUser?.campusId || null;
}
function getCurrentUserCampusKey(currentUser) {
const staffProfile = Array.isArray(currentUser?.staff_user) ? currentUser.staff_user[0] : null;
return normalizeCampusKey(currentUser?.campus?.code)
|| normalizeCampusKey(currentUser?.campus?.name)
|| normalizeCampusKey(staffProfile?.campus?.code)
|| normalizeCampusKey(staffProfile?.campus?.name)
|| null;
}
function getDisplayName(currentUser) {
const firstName = currentUser?.firstName || '';
const lastName = currentUser?.lastName || '';
const fullName = `${firstName} ${lastName}`.trim();
return fullName || currentUser?.email || 'Staff Member';
}
function getRoleName(currentUser) {
return currentUser?.app_role?.name;
}
function hasTenantWideAccess(currentUser) {
return currentUser?.app_role?.globalAccess === true
|| CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES.includes(getRoleName(currentUser));
}
function canManageCampusAttendance(currentUser) {
return currentUser?.app_role?.globalAccess === true
|| CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES.includes(getRoleName(currentUser))
|| CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES.includes(getProductRole(currentUser));
}
function assertAuthenticatedTenantUser(currentUser) {
if (currentUser?.id && getOrganizationId(currentUser)) {
return;
}
throw new ForbiddenError();
}
function assertCanManageCampusAttendance(currentUser) {
assertAuthenticatedTenantUser(currentUser);
if (canManageCampusAttendance(currentUser)) {
return;
}
throw new ForbiddenError();
}
function campusKeyFromRoute(value) {
const campusKey = normalizeCampusKey(value);
if (!campusKey) {
throw new ValidationError();
}
return campusKey;
}
function assertCanAccessCampusKey(campusKey, currentUser) {
if (hasTenantWideAccess(currentUser)) {
return;
}
const currentCampusKey = getCurrentUserCampusKey(currentUser);
if (currentCampusKey && currentCampusKey === campusKey) {
return;
}
throw new ForbiddenError();
}
function applyCampusScope(where, filter, currentUser) {
const requestedCampusKey = normalizeCampusKey(filter?.campusKey);
if (requestedCampusKey) {
assertCanAccessCampusKey(requestedCampusKey, currentUser);
where.campus_key = requestedCampusKey;
return;
}
if (hasTenantWideAccess(currentUser)) {
return;
}
const currentCampusKey = getCurrentUserCampusKey(currentUser);
if (!currentCampusKey) {
throw new ForbiddenError();
}
where.campus_key = currentCampusKey;
}
function parseLimit(value) {
if (value === undefined) {
return CAMPUS_ATTENDANCE_DEFAULT_LIMIT;
}
const limit = Number(value);
if (!Number.isInteger(limit) || limit <= 0) {
throw new ValidationError();
}
return Math.min(limit, CAMPUS_ATTENDANCE_MAX_LIMIT);
}
function requiredNonNegativeInteger(value) {
if (!Number.isInteger(value) || value < 0) {
throw new ValidationError();
}
return value;
}
function optionalText(value) {
if (typeof value !== 'string' || value.trim().length === 0) {
return null;
}
return value.trim();
}
function requiredDate(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/u.test(value)) {
throw new ValidationError();
}
return value;
}
function validateSummary(data) {
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: Number(((totalPresent / totalEnrolled) * 100).toFixed(2)),
notes: optionalText(data.notes),
};
}
function toConfigDto(record) {
const plainRecord = typeof record.get === 'function'
? record.get({ plain: true })
: record;
return {
id: plainRecord.id,
campus_key: plainRecord.campus_key,
attendance_link: plainRecord.attendance_link,
updated_by_label: plainRecord.updated_by_label,
organizationId: plainRecord.organizationId,
campusId: plainRecord.campusId,
createdById: plainRecord.createdById,
updatedById: plainRecord.updatedById,
createdAt: plainRecord.createdAt,
updatedAt: plainRecord.updatedAt,
};
}
function toSummaryDto(record) {
const plainRecord = typeof record.get === 'function'
? record.get({ plain: true })
: record;
return {
id: plainRecord.id,
campus_key: plainRecord.campus_key,
date: plainRecord.attendance_date,
total_enrolled: plainRecord.total_enrolled,
total_present: plainRecord.total_present,
total_absent: plainRecord.total_absent,
total_tardy: plainRecord.total_tardy,
attendance_percentage: Number(plainRecord.attendance_percentage),
recorded_by_label: plainRecord.recorded_by_label,
notes: plainRecord.notes,
organizationId: plainRecord.organizationId,
campusId: plainRecord.campusId,
createdById: plainRecord.createdById,
updatedById: plainRecord.updatedById,
createdAt: plainRecord.createdAt,
updatedAt: plainRecord.updatedAt,
};
}
module.exports = class CampusAttendanceService {
static async listConfigs(filter, currentUser) {
assertAuthenticatedTenantUser(currentUser);
const where = {
organizationId: getOrganizationId(currentUser),
};
applyCampusScope(where, filter, currentUser);
const result = await db.campus_attendance_config.findAndCountAll({
where,
order: [['campus_key', 'asc']],
});
return {
rows: result.rows.map(toConfigDto),
count: result.count,
};
}
static async upsertConfig(campusKeyParam, data, currentUser) {
assertCanManageCampusAttendance(currentUser);
const campusKey = campusKeyFromRoute(campusKeyParam);
assertCanAccessCampusKey(campusKey, currentUser);
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new ValidationError();
}
const attendanceLink = optionalText(data.attendance_link);
const where = {
organizationId: getOrganizationId(currentUser),
campus_key: campusKey,
};
const payload = {
campus_key: campusKey,
attendance_link: attendanceLink,
updated_by_label: getDisplayName(currentUser),
organizationId: getOrganizationId(currentUser),
campusId: getCampusId(currentUser),
updatedById: currentUser.id,
};
const transaction = await db.sequelize.transaction();
try {
const existing = await db.campus_attendance_config.findOne({ where, transaction });
const saved = existing
? await existing.update(payload, { transaction })
: await db.campus_attendance_config.create(
{
...payload,
createdById: currentUser.id,
},
{ transaction },
);
await transaction.commit();
return toConfigDto(saved);
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async listSummaries(filter, currentUser) {
assertAuthenticatedTenantUser(currentUser);
const where = {
organizationId: getOrganizationId(currentUser),
};
applyCampusScope(where, filter, currentUser);
if (filter.startDate) {
where.attendance_date = {
...(where.attendance_date || {}),
[db.Sequelize.Op.gte]: requiredDate(filter.startDate),
};
}
if (filter.endDate) {
where.attendance_date = {
...(where.attendance_date || {}),
[db.Sequelize.Op.lte]: requiredDate(filter.endDate),
};
}
const result = await db.campus_attendance_summaries.findAndCountAll({
where,
limit: parseLimit(filter.limit),
order: [['attendance_date', 'desc'], ['campus_key', 'asc']],
});
return {
rows: result.rows.map(toSummaryDto),
count: result.count,
};
}
static async upsertSummary(campusKeyParam, dateParam, data, currentUser) {
assertCanManageCampusAttendance(currentUser);
const campusKey = campusKeyFromRoute(campusKeyParam);
const attendanceDate = requiredDate(dateParam);
assertCanAccessCampusKey(campusKey, currentUser);
const validatedSummary = validateSummary(data);
const where = {
organizationId: getOrganizationId(currentUser),
campus_key: campusKey,
attendance_date: attendanceDate,
};
const payload = {
...validatedSummary,
campus_key: campusKey,
attendance_date: attendanceDate,
recorded_by_label: getDisplayName(currentUser),
organizationId: getOrganizationId(currentUser),
campusId: getCampusId(currentUser),
updatedById: currentUser.id,
};
const transaction = await db.sequelize.transaction();
try {
const existing = await db.campus_attendance_summaries.findOne({ where, transaction });
const saved = existing
? await existing.update(payload, { transaction })
: await db.campus_attendance_summaries.create(
{
...payload,
createdById: currentUser.id,
},
{ transaction },
);
await transaction.commit();
return toSummaryDto(saved);
} catch (error) {
await transaction.rollback();
throw error;
}
}
};