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>
193 lines
4.8 KiB
JavaScript
193 lines
4.8 KiB
JavaScript
const db = require('../db/models');
|
|
const ForbiddenError = require('./notifications/errors/forbidden');
|
|
const ValidationError = require('./notifications/errors/validation');
|
|
const {
|
|
STAFF_ATTENDANCE_DEFAULT_LIMIT,
|
|
STAFF_ATTENDANCE_MAX_LIMIT,
|
|
STAFF_ATTENDANCE_REPORT_ROLE_NAMES,
|
|
STAFF_ATTENDANCE_STATUSES,
|
|
STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES,
|
|
} = require('../constants/staff-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 getRoleName(currentUser) {
|
|
return currentUser?.app_role?.name;
|
|
}
|
|
|
|
function assertAuthenticatedTenantUser(currentUser) {
|
|
if (currentUser?.id && getOrganizationId(currentUser)) {
|
|
return;
|
|
}
|
|
|
|
throw new ForbiddenError();
|
|
}
|
|
|
|
function canViewReports(currentUser) {
|
|
return currentUser?.app_role?.globalAccess === true
|
|
|| STAFF_ATTENDANCE_REPORT_ROLE_NAMES.includes(getRoleName(currentUser));
|
|
}
|
|
|
|
function hasTenantWideAccess(currentUser) {
|
|
return currentUser?.app_role?.globalAccess === true
|
|
|| STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES.includes(getRoleName(currentUser));
|
|
}
|
|
|
|
function parseLimit(value) {
|
|
if (value === undefined) {
|
|
return STAFF_ATTENDANCE_DEFAULT_LIMIT;
|
|
}
|
|
|
|
const limit = Number(value);
|
|
|
|
if (!Number.isInteger(limit) || limit <= 0) {
|
|
throw new ValidationError();
|
|
}
|
|
|
|
return Math.min(limit, STAFF_ATTENDANCE_MAX_LIMIT);
|
|
}
|
|
|
|
function requiredDate(value) {
|
|
if (value === undefined) {
|
|
return null;
|
|
}
|
|
|
|
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/u.test(value)) {
|
|
throw new ValidationError();
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function applyVisibilityScope(where, currentUser) {
|
|
if (!canViewReports(currentUser)) {
|
|
where.userId = currentUser.id;
|
|
return;
|
|
}
|
|
|
|
if (hasTenantWideAccess(currentUser)) {
|
|
return;
|
|
}
|
|
|
|
const campusId = getCampusId(currentUser);
|
|
|
|
if (campusId) {
|
|
where.campusId = campusId;
|
|
}
|
|
}
|
|
|
|
function applyDateFilter(where, filter) {
|
|
const startDate = requiredDate(filter.startDate);
|
|
const endDate = requiredDate(filter.endDate);
|
|
|
|
if (startDate) {
|
|
where.attendance_date = {
|
|
...(where.attendance_date || {}),
|
|
[db.Sequelize.Op.gte]: startDate,
|
|
};
|
|
}
|
|
|
|
if (endDate) {
|
|
where.attendance_date = {
|
|
...(where.attendance_date || {}),
|
|
[db.Sequelize.Op.lte]: endDate,
|
|
};
|
|
}
|
|
}
|
|
|
|
function applyStaffScope(where, currentUser) {
|
|
where.organizationId = getOrganizationId(currentUser);
|
|
|
|
if (!hasTenantWideAccess(currentUser)) {
|
|
const campusId = getCampusId(currentUser);
|
|
|
|
if (campusId) {
|
|
where.campusId = campusId;
|
|
}
|
|
}
|
|
}
|
|
|
|
function toRecordDto(record) {
|
|
const plainRecord = typeof record.get === 'function'
|
|
? record.get({ plain: true })
|
|
: record;
|
|
|
|
return {
|
|
id: plainRecord.id,
|
|
date: plainRecord.attendance_date,
|
|
status: plainRecord.status,
|
|
note: plainRecord.note,
|
|
user_name: plainRecord.user_name,
|
|
user_role: plainRecord.user_role,
|
|
organizationId: plainRecord.organizationId,
|
|
campusId: plainRecord.campusId,
|
|
userId: plainRecord.userId,
|
|
createdAt: plainRecord.createdAt,
|
|
updatedAt: plainRecord.updatedAt,
|
|
};
|
|
}
|
|
|
|
function countStatus(records, status) {
|
|
return records.filter((record) => record.status === status).length;
|
|
}
|
|
|
|
module.exports = class StaffAttendanceService {
|
|
static async listRecords(filter, currentUser) {
|
|
assertAuthenticatedTenantUser(currentUser);
|
|
|
|
const where = {
|
|
organizationId: getOrganizationId(currentUser),
|
|
};
|
|
applyVisibilityScope(where, currentUser);
|
|
applyDateFilter(where, filter);
|
|
|
|
const result = await db.staff_attendance_records.findAndCountAll({
|
|
where,
|
|
limit: parseLimit(filter.limit),
|
|
order: [['attendance_date', 'desc'], ['user_name', 'asc']],
|
|
});
|
|
|
|
return {
|
|
rows: result.rows.map(toRecordDto),
|
|
count: result.count,
|
|
};
|
|
}
|
|
|
|
static async summary(filter, currentUser) {
|
|
assertAuthenticatedTenantUser(currentUser);
|
|
|
|
const recordsPayload = await this.listRecords(filter, currentUser);
|
|
const staffWhere = {};
|
|
applyStaffScope(staffWhere, currentUser);
|
|
staffWhere.status = 'active';
|
|
|
|
const staffCount = await db.staff.count({ where: staffWhere });
|
|
const records = recordsPayload.rows;
|
|
const present = countStatus(records, STAFF_ATTENDANCE_STATUSES.PRESENT);
|
|
const late = countStatus(records, STAFF_ATTENDANCE_STATUSES.LATE);
|
|
const absent = countStatus(records, STAFF_ATTENDANCE_STATUSES.ABSENT);
|
|
|
|
return {
|
|
staffCount,
|
|
recordsCount: recordsPayload.count,
|
|
present,
|
|
late,
|
|
absent,
|
|
};
|
|
}
|
|
};
|