40227-vm/backend/src/services/staff_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

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