Training — corporate learning, assignment & completion

The whole training model reduces to one idea: a Training is an ordered list of contentBlocks with a completionRequirement; you target it at people via a TrainingAssignment (by employee / department / employment-type / job / all); that resolves into one TrainingProgress row per staff member; as the employee views blocks / passes the quiz, a TrainingCompletionService evaluates the requirement and — on success — flips progress to COMPLETED and auto-issues a PDF TrainingCertificate. There is no live-session / Zoom integration — "external" or live content is modelled as an EXTERNAL_LINK content block ({ url }) and/or an EXTERNAL completion 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_LINK block.
  • Feed payroll. Training is informational HR; nothing here writes to payroll/GL.
  • Define its own approval flow. TrainingAssignment.requiresManagerApproval exists as a flag but no manager-approval workflow is wired to it in the current code (see §9).

Staff/employee identity uses staffId here (a User/Employee _id), distinct from most HR docs which key on employeeId. resolveByTargets returns Employee._id, so staffId on 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",
}

CompletionRequirement and ContentBlockType are registerEnumType-registered in GraphQL; ProgressStatus too. The admin mirrors all three plus an EmploymentType (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 resolverTrainingCertificate 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/recordQuizAttempt on an ASSIGNED record flips it to IN_PROGRESS.
  • updateProgressStatus(COMPLETED) also stamps completionDate.

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).

createAssignment auto-resolves once. resolveTrainingAssignment can 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; else issuePdfCertificate then create.
  • issuePdfCertificate: renders a landscape A4 PDF with PDFKit (deep-blue #014473 border + gold #D4AF37 inner border, "CERTIFICATE OF COMPLETION", staff name, course name, issue date), uploads the buffer via UploadService.upload.uploadStream (S3/CDN), returns the URI. On any error it returns null and the caller falls back to the mock /certificates/cert_<company>_<staff>_<training>.pdf URL.
  • revokeCertificate(id, reason): sets isActive: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") + GqlFeatureGuard on all three resolvers.
  • Course authoring mutations require the CASL ability write:training (@ApGqlAuthorize({ permission: { action: "write", subject: "training" } })).
  • Progress reads: trainingProgressPage requires read:training; trainingProgressById allows the owner (staffId === user.employeeId ?? user._id) or a caller with hr: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) require write: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=falseresolveByTargets returns empty → { enrolled: 0, skipped: 0 } (silently assigns nobody).
  • markContentViewed/recordQuizAttempt on someone else's progress → Forbidden.
  • Quiz with no QUIZ block or no questions → finalScore stays the client value and passedAt is 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 myTrainingProgress and downloads certs via /api/ess/training/certificate.
  • No cron in the module itself: reminderSchedule/notificationStatuses are 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

  1. No Zoom / live-session integration. "Live"/external content is an EXTERNAL_LINK content block (content: { url, openInNewTab? }) and/or the EXTERNAL completion mode (complete when completionDate is manually set). There is no meeting-provider field anywhere in the module.
  2. requiresManagerApproval is a dead flag. It is stored on the assignment but no approval workflow consumes it — assignments enroll staff immediately on resolve.
  3. No TrainingCertificate GraphQL 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 over TrainingCertificateService.
  4. Quiz score is server-authoritative. The client's score is discarded and recomputed from answers vs the QUIZ block's correctAnswers. Default pass mark is 80 when quizPassingScore is unset.
  5. staffId vs employeeId mismatch risk. Progress uses staffId (declared ref: "User"), but resolveByTargets returns Employee._id, so enrolled staffId = Employee._id. Owner checks compare against user.employeeId ?? user._id. Keep the convention consistent or self-views/permission checks break.
  6. TrainingProgressRepository.buildQuery is 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 on myTrainingProgress injecting staffId, and findByTrainingAndStaff/findByStaffId pass companyId explicitly. A raw trainingProgressPage is not auto-tenant-filtered here — guard via permissions.
  7. Reminders are configured but not dispatched in-module. reminderSchedule defaults to [deadline-7d, deadline, deadline+1d] and notificationStatuses defaults to ["ASSIGNED","IN_PROGRESS"], but no scheduler in TrainingModule sends them — wire a job/notification consumer if reminders must actually fire.
  8. Assignment resolve is best-effort on create. createAssignment swallows resolveAssignment errors (logged, not thrown) — the assignment can exist with zero enrollments if resolution fails; re-run resolveTrainingAssignment.
  9. Dates are unix-ms numbers for deadline/enrollmentDate/completionDate/reminderSchedule, but TrainingCertificate.issuedDate/revokedDate are stored as JS Date (exposed as Float in GraphQL).