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>
375 lines
10 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
};
|