Training — corporate learning, assignment & completion
The whole training model reduces to one idea: a
Trainingis an ordered list ofcontentBlockswith acompletionRequirement; you target it at people via aTrainingAssignment(by employee / department / employment-type / job / all); that resolves into oneTrainingProgressrow per staff member; as the employee views blocks / passes the quiz, aTrainingCompletionServiceevaluates the requirement and — on success — flips progress toCOMPLETEDand auto-issues a PDFTrainingCertificate. There is no live-session / Zoom integration — "external" or live content is modelled as anEXTERNAL_LINKcontent block ({ url }) and/or anEXTERNALcompletion mode (see §9).
Source: BE src/modules/hr/training (5 schemas, 4 services + completion service, 3 resolvers) · Admin src/modules/hr/training + ESS src/pages/ess/training.tsx
1. Purpose & scope
Training owns the company's e-learning catalogue and tracks who must complete what, by when:
- Author courses (
Training) from content blocks (document / video / quiz / external-link / text) with a completion rule. - Group courses (
TrainingGroup). - Assign a course to a target audience (
TrainingAssignment) with a deadline + reminder schedule. - Track per-staff progress (
TrainingProgress): content viewed, quiz attempts, status, overdue. - Auto-complete + auto-certify (
TrainingCertificate) on meeting the requirement.
It explicitly does NOT:
- Run live webinars / Zoom / Teams meetings — there is no meeting integration. Live content is just an
EXTERNAL_LINKblock. - Feed payroll. Training is informational HR; nothing here writes to payroll/GL.
- Define its own approval flow.
TrainingAssignment.requiresManagerApprovalexists as a flag but no manager-approval workflow is wired to it in the current code (see §9).
Staff/employee identity uses
staffIdhere (aUser/Employee_id), distinct from most HR docs which key onemployeeId.resolveByTargetsreturnsEmployee._id, sostaffIdon progress =Employee._id(see §9).
2. Data model
Five collections, all @HrSchema (prefix hr_) extending BaseSchema, soft-deleted via mongoose-delete.
2.1 hr_trainings — the course (training.schema.ts)
| field | type | required | description |
|---|---|---|---|
name |
string | yes | Course name. |
description |
string | yes | |
trainingGroupId |
ObjectId → TrainingGroup |
— | Optional grouping. |
contentBlocks |
embedded array (see below) | — (default []) |
Ordered learning units. |
completionRequirement |
enum CompletionRequirement |
yes | Rule that defines "done". |
quizPassingScore |
number 0–100 | — | Pass mark for quiz-based completion (default 80 if unset, see §4.3). |
timedViewMinutes |
number ≥1 | — | Minimum minutes per required block for TIMED_VIEW. |
certificateTemplate |
ObjectId | — | Template ref stamped onto the issued certificate. |
isActive |
boolean (default true) |
— | Soft on/off. |
Embedded contentBlocks[] element ({ id, type, title, order, content, isRequired, duration }): content is a free-form object whose shape depends on type (documented in the DTO):
DOCUMENT: { fileId, fileName, fileUrl }
VIDEO: { videoUrl, thumbnailUrl? }
QUIZ: { questions, passingScore }
EXTERNAL_LINK: { url, openInNewTab? } ← the closest thing to a "live session / Zoom" link
TEXT: { html }
Indexes: {companyId, isActive}, {trainingGroupId}.
2.2 hr_training_groups — catalogue grouping (training-group.schema.ts)
{ name (req), description?, isActive (default true) }. Indexes {companyId, name}, {companyId, isActive}.
2.3 hr_training_assignments — the targeting rule (training-assignment.schema.ts)
| field | type | default | description |
|---|---|---|---|
trainingId |
ObjectId → Training |
— (req, indexed) | Course being assigned. |
employeeIds |
ObjectId[] | [] |
Explicit employees. |
departmentIds |
ObjectId[] | [] |
Target whole departments. |
employmentTypes |
string[] | [] |
Target by EmploymentType (FULL_TIME, …). |
jobIds |
ObjectId[] | [] |
Target by job. |
assignAll |
boolean | false |
Target every employee in the company. |
deadline |
number (unix ms) | — (req, indexed) | Due date — copied onto each TrainingProgress. |
requiresManagerApproval |
boolean | false |
Flag only — no approval engine wired (§9). |
reminderSchedule |
number[] | [] |
Reminder dates (ms); default [deadline-7d, deadline, deadline+1d] if none given. |
isActive |
boolean | true |
|
notificationStatuses |
string[] | ["ASSIGNED","IN_PROGRESS"] |
Which progress statuses trigger reminders. |
createdBy |
ObjectId → User |
— | Set from contextSvc.user._id. |
Indexes: {trainingId, companyId}, {deadline}, {companyId, isActive}.
2.4 hr_training_progress — per-staff progress (training-progress.schema.ts)
| field | type | default | description |
|---|---|---|---|
trainingId |
ObjectId → Training |
— (req, indexed) | |
assignmentId |
ObjectId → TrainingAssignment |
— | The assignment that enrolled this staff. |
staffId |
ObjectId → User(=Employee _id) |
— (req, indexed) | Whose progress (see §9). |
enrollmentDate |
number | — (req) | When enrolled. |
completionDate |
number | — | Set when status → COMPLETED. |
status |
enum ProgressStatus |
ASSIGNED (indexed) |
Lifecycle (see §4.1). |
contentProgress |
embedded ContentProgress[] |
[] |
Per-block: { contentBlockId, viewedAt?, timeSpent (default 0), isCompleted }. |
quizAttempts |
embedded QuizAttempt[] |
[] |
Per-attempt: { attemptNumber, score 0–100, passedAt?, answers (Map), attemptedAt }. |
certificateId |
ObjectId → TrainingCertificate |
— | Linked on completion. |
certificateUrl |
string | — | Convenience copy of the cert URL. |
isOverdue |
boolean | false (indexed) |
Computed now > deadline at enroll; settable via markTrainingOverdue. |
deadline |
number | — (indexed) | Copied from the assignment. |
Indexes: {staffId, companyId}, {trainingId, status}, {status, isOverdue}, {deadline}, {staffId, status, isOverdue}.
2.5 hr_training_certificates — issued certificate (training-certificate.schema.ts)
{ trainingId (req), staffId (req), issuedDate: Date, certificateUrl (S3/CDN URL), templateUsed?, isActive (default true), revokedDate?, revokedReason? }. Indexes include the dedup key {trainingId, staffId, isActive}.
2.6 Enums (training.schema.ts + training.dto.ts)
export enum CompletionRequirement {
VIEW_ONLY = "VIEW_ONLY", // all required blocks viewed (viewedAt set)
TIMED_VIEW = "TIMED_VIEW", // all required blocks viewed AND timeSpent >= timedViewMinutes
QUIZ_BASED = "QUIZ_BASED", // any attempt score >= quizPassingScore AND passedAt set
COMBINATION = "COMBINATION", // VIEW_ONLY rule AND QUIZ_BASED rule
EXTERNAL = "EXTERNAL", // completion when completionDate is set (manually marked)
}
export enum ContentBlockType {
DOCUMENT = "DOCUMENT", VIDEO = "VIDEO", QUIZ = "QUIZ",
EXTERNAL_LINK = "EXTERNAL_LINK", TEXT = "TEXT",
}
export enum ProgressStatus { // training.dto.ts, registered in GraphQL
ASSIGNED = "ASSIGNED", IN_PROGRESS = "IN_PROGRESS", COMPLETED = "COMPLETED",
OVERDUE = "OVERDUE", EXPIRED = "EXPIRED",
}
CompletionRequirementandContentBlockTypeareregisterEnumType-registered in GraphQL;ProgressStatustoo. The admin mirrors all three plus anEmploymentType(FULL_TIME/PART_TIME/CONTRACT/INTERN) used only for assignment targeting.
3. API surface
Three resolvers, all gated @RequireFeature("HR_MODULE") + GqlFeatureGuard + @ApGqlAuthorize(). There is no certificate resolver — TrainingCertificate is a registered GraphQL type but exposes no queries/mutations; certificates are created internally by completion and downloaded via the ESS API route (§9).
Training + TrainingGroup (training.resolver.ts)
| Operation | Type | Input | Returns | Permission |
|---|---|---|---|---|
trainingPage |
Query | QueryTrainingInput |
TrainingPageResult |
HR_MODULE |
trainingById |
Query | id |
Training? |
HR_MODULE |
createTraining |
Mutation | CreateTrainingInput |
Training |
write:training (audit CREATE) |
updateTraining |
Mutation | UpdateTrainingInput |
Training |
write:training (audit UPDATE) |
addTrainingContentBlock |
Mutation | trainingId, block |
Training |
write:training |
removeTrainingContentBlock |
Mutation | trainingId, blockId |
Training |
write:training |
reorderTrainingContentBlocks |
Mutation | trainingId, blockIds[] |
Training |
write:training |
deleteTraining |
Mutation | id |
Training |
write:training (audit DELETE) |
trainingGroupPage / trainingGroupById |
Query | — | group page / TrainingGroup |
HR_MODULE |
createTrainingGroup / updateTrainingGroup / deleteTrainingGroup |
Mutation | group inputs | TrainingGroup |
HR_MODULE (audit) |
TrainingAssignment (training-assignment.resolver.ts)
| Operation | Type | Input | Returns |
|---|---|---|---|
trainingAssignmentPage |
Query | QueryTrainingAssignmentInput |
TrainingAssignmentPageResult |
trainingAssignmentById |
Query | id |
TrainingAssignment? |
previewTrainingAssignment |
Query | PreviewTrainingAssignmentInput |
AssignmentPreview { count, employees[] } — dry-run target resolution (excludes already-enrolled) |
createTrainingAssignment |
Mutation | CreateTrainingAssignmentInput |
TrainingAssignment (auto-resolves on create) |
resolveTrainingAssignment |
Mutation | id |
ResolveResult { enrolled, skipped } — (re)enroll target staff |
deleteTrainingAssignment |
Mutation | id |
TrainingAssignment |
TrainingProgress (training-progress.resolver.ts)
| Operation | Type | Input | Returns | Auth notes |
|---|---|---|---|---|
trainingProgressPage |
Query | QueryTrainingProgressInput |
page | read:training (admin/HR view) |
trainingProgressById |
Query | id |
TrainingProgress? |
owner OR hr:training:read permission, else Forbidden |
myTrainingProgress |
Query | QueryTrainingProgressInput |
page | self-scoped (staffId = user.employeeId ?? user._id) — used by ESS |
markContentViewed |
Mutation | progressId, ContentProgressInput |
TrainingProgress |
owner-only (else Forbidden); triggers completion check |
recordQuizAttempt |
Mutation | progressId, QuizAttemptInput |
TrainingProgress |
owner-only; server re-grades the quiz; triggers completion check |
markTrainingOverdue |
Mutation | progressId |
TrainingProgress |
write:training |
updateProgressStatus |
Mutation | progressId, status |
TrainingProgress |
write:training |
@ResolveField adds trainingName (from a $lookup in page(), falling back to a per-row fetch) and staffName (employeeSvc.getNameById).
4. Business rules & calculations
4.1 Progress lifecycle
markContentViewed / recordQuizAttempt
[ASSIGNED] ───────────────────────────────────────▶ [IN_PROGRESS]
│ │
│ completion requirement met │ completion requirement met
└──────────────────────┬──────────────────────────────┘
▼
[COMPLETED] (+ completionDate, + auto certificate)
OVERDUE / EXPIRED are flags/states set by markTrainingOverdue / admin updateProgressStatus.
isOverdue is also computed at enroll (now > deadline).
- First
markContentViewed/recordQuizAttempton anASSIGNEDrecord flips it toIN_PROGRESS. updateProgressStatus(COMPLETED)also stampscompletionDate.
4.2 Assignment → enrollment (resolveAssignment)
createAssignment(input):
reminders = input.reminderSchedule?.length ? input.reminderSchedule
: [deadline - 7d, deadline, deadline + 1d];
assignment = repo.create({ ...input, reminderSchedule: reminders,
notificationStatuses ?? ["ASSIGNED","IN_PROGRESS"],
createdBy: ctx.user._id });
resolveAssignment(assignment._id); // best-effort; logged, not thrown, on failure
resolveAssignment(id):
employees = employeeRepo.resolveByTargets({ employeeIds, departmentIds,
employmentTypes, jobIds, assignAll, companyId }); // OR across targets
for each employee (in parallel):
if progress already exists for (staffId, trainingId) → skipped++
else enrollStaff({ trainingId, assignmentId, staffId, deadline }) → enrolled++
return { enrolled, skipped };resolveByTargets (employee repo): if assignAll → every non-deleted employee in the company; else an $or over _id ∈ employeeIds, departmentId ∈ departmentIds, employmentType ∈ employmentTypes, jobId ∈ jobIds. No targets and not assignAll → empty (nothing assigned). Returns { _id, name }.
enrollStaff creates a TrainingProgress (ASSIGNED, isOverdue = now > deadline) — idempotent: returns the existing record if one already exists for that (trainingId, staffId, companyId).
createAssignmentauto-resolves once.resolveTrainingAssignmentcan be re-run later to pick up newly-eligible staff (already-enrolled are skipped).
4.3 Completion evaluation (training-completion.service.ts → checkAndComplete)
Called after every markContentViewed and recordQuizAttempt. If already COMPLETED → no-op (returns alreadyCompleted). Otherwise evaluateCompletion(progress, training):
completionRequirement |
"complete" when… |
|---|---|
VIEW_ONLY |
every isRequired content block has a contentProgress entry with viewedAt set (no required blocks ⇒ auto-complete). |
TIMED_VIEW |
every required block viewed and its timeSpent >= training.timedViewMinutes. |
QUIZ_BASED |
some quizAttempt has score >= quizPassingScore and passedAt set. |
COMBINATION |
VIEW_ONLY rule and QUIZ_BASED rule both pass. |
EXTERNAL |
progress.completionDate is set (i.e. someone marked it done). |
On success: generate certificate → update progress { status: COMPLETED, completionDate: now, certificateId, certificateUrl }.
4.4 Quiz grading (server-authoritative — recordQuizAttempt)
The client's submitted score is ignored; the server re-grades:
load training → find the QUIZ content block → parse content.questions[]
for each question: userAns = answers[q.id]; correct if
q.correctAnswers all present in userAns (as numbers) AND same length (exact-match, multi-answer aware)
finalScore = round(correctCount / questions.length * 100)
passingScore = training.quizPassingScore ?? 80 // default 80 if course leaves it unset
passedAt = finalScore >= passingScore ? now : undefined
The graded attempt (with finalScore/passedAt) is appended to quizAttempts, status → IN_PROGRESS, then completion is re-checked.
4.5 Certificate issuance (training-certificate.service.ts)
generateCertificate({ trainingId, staffId, certificateUrl, templateUsed }): dedup — returns the existing active cert for(staffId, trainingId, companyId)if one exists; elseissuePdfCertificatethencreate.issuePdfCertificate: renders a landscape A4 PDF with PDFKit (deep-blue#014473border + gold#D4AF37inner border, "CERTIFICATE OF COMPLETION", staff name, course name, issue date), uploads the buffer viaUploadService.upload.uploadStream(S3/CDN), returns the URI. On any error it returnsnulland the caller falls back to the mock/certificates/cert_<company>_<staff>_<training>.pdfURL.revokeCertificate(id, reason): setsisActive:false,revokedDate,revokedReason(rejects if already revoked).
4.6 Content-block ordering
createTraining/updateTraining re-index order sequentially (order = idx) and assign a uuid id to any block missing one. addContentBlock appends at order = blocks.length; removeContentBlock filters then re-indexes; reorderContentBlocks validates the id set then re-orders. UniqueOrderValidator (DTO) rejects duplicate order values on input.
4.7 Transactionality
Each service extends AbstractBaseService with a TransactionManager, but the training write methods call repo create/update directly (no explicit withRetryTransaction wrapping in the training services). Enrollment runs target employees in parallel (Promise.all), each enroll being idempotent.
5. Permissions
- Feature gate:
@RequireFeature("HR_MODULE")+GqlFeatureGuardon all three resolvers. - Course authoring mutations require the CASL ability
write:training(@ApGqlAuthorize({ permission: { action: "write", subject: "training" } })). - Progress reads:
trainingProgressPagerequiresread:training;trainingProgressByIdallows the owner (staffId === user.employeeId ?? user._id) or a caller withhr:training:read. - Progress writes by employees (
markContentViewed,recordQuizAttempt) are owner-only — an employee can only advance their own record. Admin-style status changes (markTrainingOverdue,updateProgressStatus) requirewrite:training. - See permissions-access for the CASL/permission model.
6. Flows
6.1 Author a course (admin)
Admin /hr/training → New Training → fill name/desc/group + add content blocks (DOCUMENT/VIDEO/QUIZ/EXTERNAL_LINK/TEXT)
→ set completionRequirement (+ quizPassingScore / timedViewMinutes as needed)
→ createTraining → blocks get uuid ids + sequential order → persisted
6.2 Assign + enroll
1. Admin → training detail → Assign → pick targets (employees / departments / employmentTypes / jobs / assignAll) + deadline
2. (optional) previewTrainingAssignment → shows count + names of NEW (not-yet-enrolled) employees
3. createTrainingAssignment → reminderSchedule defaulted → auto resolveAssignment
→ resolveByTargets(OR of targets) → for each: skip if enrolled else enrollStaff(ASSIGNED)
← { enrolled, skipped }
4. Each enrolled employee now has a TrainingProgress (deadline copied, isOverdue computed)
6.3 Employee completes a course (ESS)
1. Employee /ess/training → myTrainingProgress (self-scoped) → list with status pill + % bar
2. Opens a course → views blocks → markContentViewed(progressId, {contentBlockId, viewedAt, timeSpent}) [owner-only]
→ status ASSIGNED→IN_PROGRESS → checkAndComplete()
3. Quiz block → recordQuizAttempt(progressId, {answers, attemptNumber, attemptedAt}) [owner-only]
→ server re-grades → score/passedAt → checkAndComplete()
4. Requirement met → status COMPLETED + completionDate + auto certificate (PDF → S3) + certificateUrl
5. Employee downloads cert → GET /api/ess/training/certificate?url=...&name=... (proxied attachment)
6.4 Unhappy paths
- Assignment with no targets and
assignAll=false→resolveByTargetsreturns empty →{ enrolled: 0, skipped: 0 }(silently assigns nobody). markContentViewed/recordQuizAttempton someone else's progress →Forbidden.- Quiz with no QUIZ block or no questions →
finalScorestays the client value andpassedAtis not set by the grader path (quiz cannot pass) — author must include a QUIZ block. - PDF generation/upload failure → cert still created with the mock URL (no hard failure).
7. Admin UI
| Area | Route | Module |
|---|---|---|
| Training catalogue + groups + assignments + progress | /hr/training |
src/modules/hr/training |
| Training detail (blocks, assignments, progress) | /hr/training/[id] (via training-detail.tsx) |
same |
| Employee-facing list | /ess/training |
src/pages/ess/training.tsx (uses myTrainingProgress) |
context.tsx (useTrainingState) is the single Apollo consumer and exposes: fetchTrainingPage, fetchGroupPage, fetchAssignmentPage, createTraining/updateTraining/deleteTraining, createGroup/updateGroup/deleteGroup, createAssignment/deleteAssignment, addContentBlock/removeContentBlock, fetchProgressPage, markOverdue, toggleContentBlock, updateProgressStatus, fetchTrainingById, previewAssignment, resolveAssignment, searchEmployees, searchDepartments, uploadFile/deleteFile (for DOCUMENT/VIDEO block assets via fileUpload).
constants.tsx holds the UI maps: completionLabel/completionConfig/completionPill (per requirement), blockTypeIcon/blockTypeColor (per block type), progressStatusColor, EMPLOYMENT_TYPE_OPTIONS, CONTENT_BLOCK_OPTIONS, COMPLETION_OPTIONS. training-detail.tsx is the large detail screen (content-block editor, assignment builder with live previewAssignment, progress table).
Notable UX: assignment builder shows a live preview count of new enrollees before committing; per-block file upload; progress table shows status pills and overdue flags; the ESS list renders a status-derived % bar (ASSIGNED 0 / IN_PROGRESS 50 / COMPLETED 100).
8. Dependencies & integrations
TrainingModule imports: Mongoose models (5), AuthModule, SubscriptionModule (feature gate), EmployeeModule, UserModule, ApUploadModule (certificate PDF upload).
- Employee (employee) —
resolveByTargets(assignment audience),getNameById(cert +staffName). Hard dependency. - Upload (
zync-nest-library UploadService) — uploads the generated certificate PDF to S3/CDN. - PDFKit — generates the certificate document in-process.
- ESS (ess) — surfaces
myTrainingProgressand downloads certs via/api/ess/training/certificate. - No cron in the module itself:
reminderSchedule/notificationStatusesare stored but no scheduler that reads them is present in this module (reminders are configured, not yet dispatched here — confirm against notifications). - No Zoom / live-meeting service.
9. Gotchas & project-specific rules
- No Zoom / live-session integration. "Live"/external content is an
EXTERNAL_LINKcontent block (content: { url, openInNewTab? }) and/or theEXTERNALcompletion mode (complete whencompletionDateis manually set). There is no meeting-provider field anywhere in the module. requiresManagerApprovalis a dead flag. It is stored on the assignment but no approval workflow consumes it — assignments enroll staff immediately on resolve.- No
TrainingCertificateGraphQL resolver. The type is registered but has no queries/mutations. Certificates are created only by the completion path and downloaded via the ESS REST proxy (/api/ess/training/certificate). To list/revoke via API you'd add a resolver overTrainingCertificateService. - Quiz score is server-authoritative. The client's
scoreis discarded and recomputed fromanswersvs the QUIZ block'scorrectAnswers. Default pass mark is 80 whenquizPassingScoreis unset. staffIdvsemployeeIdmismatch risk. Progress usesstaffId(declaredref: "User"), butresolveByTargetsreturnsEmployee._id, so enrolledstaffId=Employee._id. Owner checks compare againstuser.employeeId ?? user._id. Keep the convention consistent or self-views/permission checks break.TrainingProgressRepository.buildQueryis NOT employee-scoped or company-scoped. Unlike leave/claim (ESS plan), it only filters by the explicit query keys +deleted. Self-scoping for ESS relies onmyTrainingProgressinjectingstaffId, andfindByTrainingAndStaff/findByStaffIdpasscompanyIdexplicitly. A rawtrainingProgressPageis not auto-tenant-filtered here — guard via permissions.- Reminders are configured but not dispatched in-module.
reminderScheduledefaults to[deadline-7d, deadline, deadline+1d]andnotificationStatusesdefaults to["ASSIGNED","IN_PROGRESS"], but no scheduler inTrainingModulesends them — wire a job/notification consumer if reminders must actually fire. - Assignment resolve is best-effort on create.
createAssignmentswallowsresolveAssignmenterrors (logged, not thrown) — the assignment can exist with zero enrollments if resolution fails; re-runresolveTrainingAssignment. - Dates are unix-ms numbers for
deadline/enrollmentDate/completionDate/reminderSchedule, butTrainingCertificate.issuedDate/revokedDateare stored as JSDate(exposed asFloatin GraphQL).