Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Stivenz3/Nexu/llms.txt

Use this file to discover all available pages before exploring further.

Every time a user submits a final exam, LessonExamPage checks whether this passing score is the last piece needed to unlock the full BPM course certificate. If all 8 lessons are now passed, it triggers the issuance pipeline — a chain of service calls that culminates in two atomic Firestore writes and a redirect to the official certificate view.

Trigger: LessonExamPage

After a user passes an exam (score >= passingPercent), LessonExamPage calls tryIssueCourseCertificateAfterLesson with the current lesson context and the authenticated user’s profile data:
// src/pages/LessonExamPage.tsx
const cert = await tryIssueCourseCertificateAfterLesson({
  userId: user.id,
  lessonId,
  lessonTitle,
  finalScore: score,
  userName: user.fullName,
  userDocument: `${user.documentType?.toUpperCase() ?? 'CC'}. ${user.documentNumber}`,
  documentType: user.documentType ?? 'cc',
  documentNumber: user.documentNumber,
})
if (cert) certificateId = cert.certificateId
If a certificate is returned, the results screen shows a “Ver mi certificado del curso” button that navigates to /certificado/documento.

Input Interface

The full input shape passed from LessonExamPage to the service:
interface IssueCertificateInput {
  userId: string
  lessonId: string
  lessonTitle: string
  finalScore: number
  userName: string
  userDocument: string
  documentType: string
  documentNumber: string
  companyId?: string | null
}

Complete Issuance Flow

1

Exam submitted and score calculated

LessonExamPage scores the exam. If score >= examConfig.passingPercent, it calls saveExamResult to persist the result in users/{userId}/lessonProgress/{lessonId}, then proceeds to certificate issuance.
2

Check all 8 lessons passed

tryIssueCourseCertificateAfterLesson calls areAllCourseLessonsPassed(userId). This iterates over all 8 lesson positions (order 1–8), verifies each lesson is published (isActive: true) in Firestore, and confirms progress.status === 'passed' for each.If any lesson is unpublished or not yet passed, the function returns null — no certificate is created.
3

Idempotency check — look for existing certificate

getCourseCertificate(userId) queries certificates where userId == userId and courseId == 'nexu_bpm_curso'. If a certificate already exists, it is returned immediately. No duplicate is ever written.
4

Build completed lessons snapshot

buildCompletedLessonsSnapshot(userId) iterates over the 8 lessons again and builds an array of CompletedLessonSnapshot objects — one per passed lesson — capturing lessonId, lessonTitle, and finalScore (from bestEvalScore in the progress record).
5

Compute average score

The average score across all completed lessons is calculated:
const avgScore = Math.round(
  completedLessons.reduce((s, l) => s + l.finalScore, 0) / completedLessons.length
)
This avgScore becomes the finalScore stored in the certificate record.
6

Generate certificate identifiers

issueCourseCertificate generates two identifiers:
  • certificateId — deterministic: cert_{userId.slice(0, 8)}_nexu_bpm_curso
  • verifyCode — random 13-character code using the character set ABCDEFGHJKLMNPQRSTUVWXYZ23456789, formatted as NX-XXXXXXXXXX
The qrVerifyUrl is built by passing verifyCode to buildVerifyUrl(verifyCode) from src/lib/verifyUrl.ts.
7

Write private record to certificates/{certificateId}

A CertificateRecord document is written with setDoc. It contains the full unmasked userDocument, the completedLessons snapshot, timestamps (issuedAt = now, expiresAt = +1 year), and isValid: true.Firestore rules allow this create only when request.auth.uid == data.userId and data.isValid == true. Updates and deletes are denied.
8

Write public record to certificatePublic/{verifyCode}

A CertificatePublicRecord document is written to certificatePublic/{verifyCode}. The document number is masked before writing:
function maskDocument(documentType: string, documentNumber: string): string {
  const tail = documentNumber.slice(-4)
  return `${documentType.toUpperCase()}. ***${tail}`
  // e.g. "CC. ***6789"
}
The public document is readable by anyone (allow read: if true). Create requires auth; update and delete are denied.
9

Redirect to /certificado/documento

LessonExamPage receives the returned CertificateView. The results screen shows the “Ver mi certificado del curso” button, which navigates to /certificado/documento — the official printable certificate with QR and PDF download.

Document ID Determinism

The certificateId is deterministic, not random:
function generateCourseCertificateId(userId: string): string {
  return `cert_${userId.slice(0, 8)}_${NEXU_COURSE_ID}`
  // e.g. "cert_aB3xKq9z_nexu_bpm_curso"
}
This means setDoc with that ID is safe to call multiple times — the inner getCourseCertificate guard prevents any actual second write, but the deterministic ID also ensures there can never be two certificates for the same user in the same Firestore collection.

Deprecated: issueCertificate

The original issueCertificate(input) export is marked @deprecated. It now delegates internally to tryIssueCourseCertificateAfterLesson and throws an error if all 8 lessons are not yet passed. Do not use it in new code.
/** @deprecated Use tryIssueCourseCertificateAfterLesson */
export async function issueCertificate(
  input: IssueCertificateInput
): Promise<CertificateView>
Adding a new lesson? You do not need to change anything in certificateService.ts. LessonExamPage already calls tryIssueCourseCertificateAfterLesson with the correct lessonId and lessonTitle derived from the route parameter. As long as the new lesson exists in Firestore with a valid lessonId and isActive: true, the certificate pipeline will include it automatically in the 8-lesson check.

Build docs developers (and LLMs) love