Skip to content

Commit ad050a5

Browse files
mlehotskylfclaude
andauthored
feat(training): add enrolled trainings tab with ongoing and completed sections (#418)
* docs(training): add enrolled trainings tab design spec Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * docs(training): align enrolled trainings spec with codebase patterns - Add shared constants (TRAINING_PRODUCT_TYPE, CERTIFICATION_PRODUCT_TYPE, CONTINUE_LEARNING_URL) to avoid inline string literals - Fix frontend service to use HttpParams pattern (matching project.service.ts) - Fix controller query param extraction to match events.controller.ts pattern - Fix component inputs to use input()/input.required() (not InputSignal<T>) - Add computed signals section with private init functions - Clarify toSignal() usage without initialValue for loading state detection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * docs: add enrolled trainings tab implementation plan Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * feat(training): add training constants to shared package Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * feat(training): add EnrollmentRow, TrainingEnrollment; extend Certification and CertificateRow with level Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * feat(training): add getEnrollments; add productType filter and LEVEL to getCertifications Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * feat(training): add getEnrollments endpoint; add productType query param to getCertifications Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * feat(training): add getEnrollments and productType param to frontend training service Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * feat(training): add TrainingCardComponent for ongoing and completed trainings Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * feat(training): add enrollments and completedTrainings signals to dashboard; filter certifications by product type Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * feat(training): implement enrolled trainings tab with ongoing and completed sections Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * style: apply formatting Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * chore: remove plan doc from tracking (keep local only) Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * chore: remove design spec from tracking (keep local only) Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * fix(styles): apply Roboto Slab to h1 and Inter to body h1 headings should use the display (Roboto Slab) font per the design system. Body was missing an explicit Inter declaration causing fallback to browser default sans-serif. Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * fix(trainings): add separator line below section headings Add border-b to 'Ongoing trainings' and 'Completed trainings' section headings to match the reference design. LFXV2-enrolled-trainings-tab Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * fix(trainings): remove unused fields and clean up SQL formatting - Remove PRODUCT_TYPE from CertificateRow and EnrollmentRow — the field is not selected in the Snowflake queries - Remove USER_NAME from EnrollmentRow — not selected in query - Fix SQL query string concatenation producing double spaces - Add comment clarifying variant/type contract in TrainingCardComponent Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * fix(training): correct EnrollmentRow nullable fields to match Snowflake nullability LFXV2-enrolled-trainings-tab Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * fix(training): address reviewer feedback on training tab implementation - Use CSS variables (--font-inter, --font-display) instead of hardcoded font strings in styles.scss - Parameterize ENROLLMENTS_QUERY with TRAINING_PRODUCT_TYPE constant instead of hardcoded string - Rename 'Continue Learning' to 'Go to Training Portal' to set correct expectations; add TODO for course-specific URL - Replace ! non-null assertions with ?. safe access in enrolled trainings template - Use [ngClass] binding instead of {{ }} interpolation for levelClasses - Inline trivial computed signals (hasImage, isOngoing, dateLabel) per component-organization conventions - Add TODO comments for !important button size overrides Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * fix(training): use production ANALYTICS schema in SQL queries Replace ANALYTICS_DEV.LF_MICHAL_platinum_lfx_one.* with ANALYTICS.PLATINUM_LFX_ONE.* for both certificates and enrollments queries. LFXV2-2095 Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * feat(training): link ongoing training cards to specific course pages Add COURSE_SLUG to EnrollmentRow and TrainingEnrollment interfaces and include it in the ENROLLMENTS_QUERY SELECT. The training card now computes a course-specific URL (COURSE_URL_PREFIX + courseSlug) when the slug is available, falling back to the generic training portal dashboard URL. Button label restored to 'Continue Learning'. LFXV2-2095 Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> * fix(styles): scope Roboto Slab to training page, restore h1 defaults Global h1 Roboto Slab font-family had no ticket backing and affected every unstyled h1 across the app. Reverted to pre-PR default (text-xl font-semibold) and applied font-display class directly on the training page h1 instead. LFXV2-1328 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> --------- Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6c47635 commit ad050a5

12 files changed

Lines changed: 388 additions & 26 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
2+
<!-- SPDX-License-Identifier: MIT -->
3+
4+
<div class="bg-white rounded-xl border shadow-sm hover:border-blue-400 transition-all" data-testid="training-card">
5+
<div class="p-5 flex items-start gap-5">
6+
<!-- Col 1: Logo -->
7+
<div
8+
class="flex-shrink-0 w-14 h-14 flex items-center justify-center overflow-hidden rounded-xl bg-gray-50 border border-gray-100"
9+
data-testid="training-card-image-container">
10+
@if (hasImage()) {
11+
<img [src]="training().imageUrl" [alt]="training().name" class="w-14 h-14 object-contain" data-testid="training-card-image" />
12+
} @else {
13+
<i class="fa-light fa-book-open text-2xl text-gray-400" data-testid="training-card-icon-fallback"></i>
14+
}
15+
</div>
16+
17+
<!-- Col 2: Main content -->
18+
<div class="flex-1 min-w-0 flex flex-col gap-3">
19+
<!-- Top row: name + level badge + issuedBy | Continue Learning button -->
20+
<div class="flex items-start justify-between gap-4">
21+
<div class="flex flex-col gap-1 min-w-0">
22+
<div class="flex items-center gap-2 flex-wrap">
23+
<h3 class="text-sm font-semibold text-gray-900 leading-snug" data-testid="training-card-name">{{ training().name }}</h3>
24+
@if (training().level) {
25+
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium" [ngClass]="levelClasses()" data-testid="training-card-level-badge">
26+
{{ training().level }}
27+
</span>
28+
}
29+
</div>
30+
<p class="text-xs text-gray-500" data-testid="training-card-issued-by">{{ training().issuedBy }}</p>
31+
</div>
32+
33+
@if (isOngoing()) {
34+
<div class="flex-shrink-0" data-testid="training-card-continue-action">
35+
<!-- TODO: replace !important overrides with a size="sm" input on ButtonComponent (LFXV2-TODO) -->
36+
<lfx-button
37+
severity="secondary"
38+
styleClass="!text-xs !h-8 !py-0"
39+
label="Continue Learning"
40+
icon="fa-light fa-arrow-right"
41+
[href]="continueLearningUrl()"
42+
target="_blank"
43+
rel="noopener noreferrer"
44+
[outlined]="true"
45+
data-testid="training-card-continue-btn" />
46+
</div>
47+
}
48+
</div>
49+
50+
<!-- Description -->
51+
<p class="text-sm text-gray-600 line-clamp-2" [title]="training().description" data-testid="training-card-description">
52+
{{ training().description }}
53+
</p>
54+
55+
<!-- Bottom row: date meta + download button (completed only) -->
56+
<div class="flex items-end justify-between gap-4 border-t border-gray-100 pt-3" data-testid="training-card-meta-row">
57+
<div class="flex flex-wrap gap-x-8 gap-y-1.5 text-xs" data-testid="training-card-meta">
58+
<div class="flex flex-col gap-0.5">
59+
<span class="text-gray-400">{{ dateLabel() }}</span>
60+
<span class="text-gray-700 font-medium" data-testid="training-card-date">{{ date() | date: 'MMM d, y' }}</span>
61+
</div>
62+
</div>
63+
64+
@if (downloadUrl()) {
65+
<div class="flex-shrink-0" data-testid="training-card-actions">
66+
<!-- TODO: replace !important overrides with a size="sm" input on ButtonComponent (LFXV2-TODO) -->
67+
<lfx-button
68+
severity="secondary"
69+
styleClass="!text-xs !h-8 !py-0"
70+
label="Download Certificate"
71+
icon="fa-light fa-arrow-down-to-line"
72+
[href]="downloadUrl()!"
73+
target="_blank"
74+
rel="noopener noreferrer"
75+
[outlined]="true"
76+
data-testid="training-card-download-btn" />
77+
</div>
78+
}
79+
</div>
80+
</div>
81+
</div>
82+
</div>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
// Generated with [Claude Code](https://claude.ai/code)
5+
6+
import { DatePipe, NgClass } from '@angular/common';
7+
import { ChangeDetectionStrategy, Component, computed, input, Signal } from '@angular/core';
8+
import { Certification, TrainingEnrollment } from '@lfx-one/shared/interfaces';
9+
import { CONTINUE_LEARNING_URL, COURSE_URL_PREFIX } from '@lfx-one/shared/constants';
10+
11+
import { ButtonComponent } from '@components/button/button.component';
12+
13+
@Component({
14+
selector: 'lfx-training-card',
15+
imports: [ButtonComponent, DatePipe, NgClass],
16+
templateUrl: './training-card.component.html',
17+
changeDetection: ChangeDetectionStrategy.OnPush,
18+
})
19+
export class TrainingCardComponent {
20+
// ─── Inputs ────────────────────────────────────────────────────────────────
21+
public readonly training = input.required<TrainingEnrollment | Certification>();
22+
public readonly variant = input<'ongoing' | 'completed'>('ongoing');
23+
24+
// ─── Computed Signals ──────────────────────────────────────────────────────
25+
protected readonly hasImage = computed(() => !!this.training().imageUrl);
26+
protected readonly isOngoing = computed(() => this.variant() === 'ongoing');
27+
protected readonly dateLabel = computed(() => (this.variant() === 'ongoing' ? 'Enrolled' : 'Completed'));
28+
protected readonly date: Signal<string> = this.initDate();
29+
protected readonly levelClasses: Signal<string> = this.initLevelClasses();
30+
protected readonly downloadUrl: Signal<string | null> = this.initDownloadUrl();
31+
protected readonly continueLearningUrl: Signal<string> = this.initContinueLearningUrl();
32+
33+
// ─── Private Initializers ──────────────────────────────────────────────────
34+
private initDate(): Signal<string> {
35+
return computed(() => {
36+
const t = this.training();
37+
// variant='ongoing' → caller passes TrainingEnrollment; variant='completed' → Certification
38+
if (this.variant() === 'ongoing') {
39+
return (t as TrainingEnrollment).enrolledDate ?? '';
40+
}
41+
return (t as Certification).issuedDate ?? '';
42+
});
43+
}
44+
45+
private initLevelClasses(): Signal<string> {
46+
return computed(() => {
47+
const level = this.training().level;
48+
if (level === 'Beginner') return 'bg-blue-50 text-blue-700 border border-blue-200';
49+
if (level === 'Intermediate') return 'bg-purple-50 text-purple-700 border border-purple-200';
50+
if (level === 'Advanced') return 'bg-orange-50 text-orange-700 border border-orange-200';
51+
return 'bg-gray-50 text-gray-500 border border-gray-200';
52+
});
53+
}
54+
55+
private initDownloadUrl(): Signal<string | null> {
56+
return computed(() => {
57+
if (this.variant() !== 'completed') return null;
58+
const t = this.training() as Certification;
59+
return t.downloadUrl ?? null;
60+
});
61+
}
62+
63+
private initContinueLearningUrl(): Signal<string> {
64+
return computed(() => {
65+
const t = this.training() as TrainingEnrollment;
66+
return t.courseSlug ? `${COURSE_URL_PREFIX}${t.courseSlug}` : CONTINUE_LEARNING_URL;
67+
});
68+
}
69+
}

apps/lfx-one/src/app/modules/trainings/trainings-dashboard/trainings-dashboard.component.html

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<div class="container mx-auto px-4 sm:px-6 lg:px-8" data-testid="trainings-dashboard">
55
<!-- Page Header -->
66
<div class="mb-8">
7-
<h1 data-testid="trainings-title">Training &amp; Certifications</h1>
7+
<h1 class="font-display" data-testid="trainings-title">Training &amp; Certifications</h1>
88
<p class="mt-2 text-gray-500" data-testid="trainings-subtitle">{{ subtitle }}</p>
99
</div>
1010

@@ -65,15 +65,67 @@ <h2 class="text-lg font-semibold text-gray-700">No certifications yet</h2>
6565
}
6666
}
6767

68-
<!-- ── TAB: Enrolled Trainings (placeholder) ────────────────────────── -->
68+
<!-- ── TAB: Enrolled Trainings ──────────────────────────────────────── -->
6969
@if (activeTab() === 'enrolled-trainings') {
70-
<div class="flex flex-col items-center justify-center py-20 text-center gap-4" data-testid="trainings-empty-state">
71-
<i class="fa-light fa-book-open text-6xl text-gray-300"></i>
72-
<div class="flex flex-col gap-2 max-w-md">
73-
<h2 class="text-lg font-semibold text-gray-700">Coming soon</h2>
74-
<p class="text-gray-500 text-sm">Enrolled trainings will be available here soon.</p>
70+
@if (enrollments() === undefined || completedTrainings() === undefined) {
71+
<!-- Loading state -->
72+
<div class="flex flex-col gap-4" data-testid="trainings-loading">
73+
@for (i of [1, 2, 3]; track i) {
74+
<div class="bg-white rounded-xl border shadow-sm p-5 animate-pulse">
75+
<div class="flex items-start gap-5">
76+
<div class="w-14 h-14 bg-gray-200 rounded-xl"></div>
77+
<div class="flex-1 flex flex-col gap-3">
78+
<div class="h-4 bg-gray-200 rounded w-1/3"></div>
79+
<div class="h-3 bg-gray-200 rounded w-2/3"></div>
80+
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
81+
</div>
82+
</div>
83+
</div>
84+
}
7585
</div>
76-
</div>
86+
} @else if ((enrollments()?.length ?? 0) === 0 && (completedTrainings()?.length ?? 0) === 0) {
87+
<!-- Empty state: both sections empty -->
88+
<div class="flex flex-col items-center justify-center py-20 text-center gap-4" data-testid="trainings-empty-state">
89+
<i class="fa-light fa-book-open text-6xl text-gray-300"></i>
90+
<div class="flex flex-col gap-2 max-w-md">
91+
<h2 class="text-lg font-semibold text-gray-700">No enrolled trainings yet</h2>
92+
<p class="text-gray-500 text-sm">Browse the Linux Foundation course catalog to start your learning journey.</p>
93+
</div>
94+
<lfx-button
95+
label="Browse Courses →"
96+
href="https://training.linuxfoundation.org"
97+
target="_blank"
98+
rel="noopener noreferrer"
99+
severity="secondary"
100+
[outlined]="true"
101+
data-testid="trainings-empty-cta" />
102+
</div>
103+
} @else {
104+
<!-- Content: show non-empty sections only -->
105+
<div class="flex flex-col gap-8" data-testid="trainings-content">
106+
@if ((enrollments()?.length ?? 0) > 0) {
107+
<div data-testid="trainings-ongoing-section">
108+
<h2 class="text-base font-semibold text-gray-800 pb-3 mb-4 border-b border-gray-200">Ongoing trainings</h2>
109+
<div class="flex flex-col gap-4" data-testid="trainings-ongoing-list">
110+
@for (enrollment of (enrollments() ?? []); track enrollment.id) {
111+
<lfx-training-card [training]="enrollment" variant="ongoing" data-testid="training-card-item" />
112+
}
113+
</div>
114+
</div>
115+
}
116+
117+
@if ((completedTrainings()?.length ?? 0) > 0) {
118+
<div data-testid="trainings-completed-section">
119+
<h2 class="text-base font-semibold text-gray-800 pb-3 mb-4 border-b border-gray-200">Completed trainings</h2>
120+
<div class="flex flex-col gap-4" data-testid="trainings-completed-list">
121+
@for (cert of (completedTrainings() ?? []); track cert.id) {
122+
<lfx-training-card [training]="cert" variant="completed" data-testid="training-completed-item" />
123+
}
124+
</div>
125+
</div>
126+
}
127+
</div>
128+
}
77129
}
78130

79131
<!-- ── TAB: Rewards (placeholder) ───────────────────────────────────── -->

apps/lfx-one/src/app/modules/trainings/trainings-dashboard/trainings-dashboard.component.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55

66
import { ChangeDetectionStrategy, Component, inject, signal, Signal } from '@angular/core';
77
import { toSignal } from '@angular/core/rxjs-interop';
8-
import { Certification, FilterPillOption } from '@lfx-one/shared/interfaces';
8+
import { Certification, FilterPillOption, TrainingEnrollment } from '@lfx-one/shared/interfaces';
9+
import { CERTIFICATION_PRODUCT_TYPE, TRAINING_PRODUCT_TYPE } from '@lfx-one/shared/constants';
910

1011
import { ButtonComponent } from '@components/button/button.component';
1112
import { FilterPillsComponent } from '@components/filter-pills/filter-pills.component';
1213
import { TrainingService } from '@shared/services/training.service';
1314
import { CertificationCardComponent } from '../components/certification-card/certification-card.component';
15+
import { TrainingCardComponent } from '../components/training-card/training-card.component';
1416

1517
const PAGE_SUBTITLE = 'Track your Linux Foundation learning journey — active certifications, enrolled courses, rewards, and resources all in one place.';
1618

@@ -35,7 +37,7 @@ const USEFUL_LINKS = [
3537

3638
@Component({
3739
selector: 'lfx-trainings-dashboard',
38-
imports: [ButtonComponent, CertificationCardComponent, FilterPillsComponent],
40+
imports: [ButtonComponent, CertificationCardComponent, FilterPillsComponent, TrainingCardComponent],
3941
templateUrl: './trainings-dashboard.component.html',
4042
changeDetection: ChangeDetectionStrategy.OnPush,
4143
})
@@ -53,6 +55,8 @@ export class TrainingsDashboardComponent {
5355

5456
// ─── Computed Signals ──────────────────────────────────────────────────────
5557
protected readonly certifications: Signal<Certification[] | undefined> = this.initCertifications();
58+
protected readonly enrollments: Signal<TrainingEnrollment[] | undefined> = this.initEnrollments();
59+
protected readonly completedTrainings: Signal<Certification[] | undefined> = this.initCompletedTrainings();
5660

5761
// ─── Protected Methods ─────────────────────────────────────────────────────
5862
protected onTabChange(tabId: string): void {
@@ -61,6 +65,14 @@ export class TrainingsDashboardComponent {
6165

6266
// ─── Private Initializers ──────────────────────────────────────────────────
6367
private initCertifications(): Signal<Certification[] | undefined> {
64-
return toSignal(this.trainingService.getCertifications());
68+
return toSignal(this.trainingService.getCertifications(CERTIFICATION_PRODUCT_TYPE));
69+
}
70+
71+
private initEnrollments(): Signal<TrainingEnrollment[] | undefined> {
72+
return toSignal(this.trainingService.getEnrollments());
73+
}
74+
75+
private initCompletedTrainings(): Signal<Certification[] | undefined> {
76+
return toSignal(this.trainingService.getCertifications(TRAINING_PRODUCT_TYPE));
6577
}
6678
}

apps/lfx-one/src/app/shared/services/training.service.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33

44
// Generated with [Claude Code](https://claude.ai/code)
55

6-
import { HttpClient } from '@angular/common/http';
6+
import { HttpClient, HttpParams } from '@angular/common/http';
77
import { inject, Injectable } from '@angular/core';
8-
import { Certification } from '@lfx-one/shared/interfaces';
8+
import { Certification, TrainingEnrollment } from '@lfx-one/shared/interfaces';
99
import { catchError, Observable, of } from 'rxjs';
1010

1111
@Injectable({
@@ -14,7 +14,15 @@ import { catchError, Observable, of } from 'rxjs';
1414
export class TrainingService {
1515
private readonly http = inject(HttpClient);
1616

17-
public getCertifications(): Observable<Certification[]> {
18-
return this.http.get<Certification[]>('/api/training/certifications').pipe(catchError(() => of([])));
17+
public getCertifications(productType?: string): Observable<Certification[]> {
18+
let params = new HttpParams();
19+
if (productType) {
20+
params = params.set('productType', productType);
21+
}
22+
return this.http.get<Certification[]>('/api/training/certifications', { params }).pipe(catchError(() => of([])));
23+
}
24+
25+
public getEnrollments(): Observable<TrainingEnrollment[]> {
26+
return this.http.get<TrainingEnrollment[]>('/api/training/enrollments').pipe(catchError(() => of([])));
1927
}
2028
}

apps/lfx-one/src/server/controllers/training.controller.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export class TrainingController {
1515

1616
/**
1717
* GET /api/training/certifications
18-
* Get all certifications for the authenticated user
18+
* Get certifications for the authenticated user, optionally filtered by productType
1919
*/
2020
public async getCertifications(req: Request, res: Response, next: NextFunction): Promise<void> {
2121
const startTime = logger.startOperation(req, 'get_certifications');
@@ -30,10 +30,12 @@ export class TrainingController {
3030
}
3131

3232
const username = stripAuthPrefix(rawUsername);
33-
const certifications = await this.trainingService.getCertifications(req, username);
33+
const productType = req.query['productType'] ? String(req.query['productType']) : undefined;
34+
const certifications = await this.trainingService.getCertifications(req, username, productType);
3435

3536
logger.success(req, 'get_certifications', startTime, {
3637
result_count: certifications.length,
38+
product_type: productType,
3739
});
3840

3941
res.json(certifications);
@@ -42,4 +44,34 @@ export class TrainingController {
4244
next(error);
4345
}
4446
}
47+
48+
/**
49+
* GET /api/training/enrollments
50+
* Get ongoing training enrollments for the authenticated user
51+
*/
52+
public async getEnrollments(req: Request, res: Response, next: NextFunction): Promise<void> {
53+
const startTime = logger.startOperation(req, 'get_enrollments');
54+
55+
try {
56+
const rawUsername = await getUsernameFromAuth(req);
57+
58+
if (!rawUsername) {
59+
throw new AuthenticationError('User authentication required', {
60+
operation: 'get_enrollments',
61+
});
62+
}
63+
64+
const username = stripAuthPrefix(rawUsername);
65+
const enrollments = await this.trainingService.getEnrollments(req, username);
66+
67+
logger.success(req, 'get_enrollments', startTime, {
68+
result_count: enrollments.length,
69+
});
70+
71+
res.json(enrollments);
72+
} catch (error) {
73+
logger.error(req, 'get_enrollments', startTime, error);
74+
next(error);
75+
}
76+
}
4577
}

apps/lfx-one/src/server/routes/training.route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ const router = Router();
1111
const trainingController = new TrainingController();
1212

1313
router.get('/certifications', (req, res, next) => trainingController.getCertifications(req, res, next));
14+
router.get('/enrollments', (req, res, next) => trainingController.getEnrollments(req, res, next));
1415

1516
export default router;

0 commit comments

Comments
 (0)