refactor(frontend): use centralized constants and add JSDoc

- Add comprehensive JSDoc to StudentService and EnrollmentService
- Replace magic numbers with ENROLLMENT_LIMITS constants
- Use RESTRICTION_TRANSLATIONS for enrollment messages
- Update CreditsPipe to use MAX_CREDITS constant
- Expose maxCredits and maxSubjects in enrollment component

This improves code maintainability and developer experience.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andrés Eduardo García Márquez 2026-01-08 00:31:05 -05:00
parent 1b41224514
commit 275e377492
4 changed files with 133 additions and 13 deletions

View File

@ -27,10 +27,30 @@ interface UnenrollResult {
unenrollStudent: DeletePayload; unenrollStudent: DeletePayload;
} }
/**
* Service for managing student enrollments in subjects.
* Handles business rules: max 3 subjects, no duplicate professors.
*
* @example
* ```typescript
* // Enroll student in a subject
* this.enrollmentService.enrollStudent({ studentId: 1, subjectId: 2 }).subscribe(result => {
* if (result.errors?.length) {
* // Handle validation errors
* }
* });
* ```
*/
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class EnrollmentService { export class EnrollmentService {
private apollo = inject(Apollo); private apollo = inject(Apollo);
/**
* Retrieves a student with their current enrollments.
*
* @param studentId - The student's unique identifier
* @returns Observable with student data including enrollments array
*/
getStudentWithEnrollments(studentId: number): Observable<{ data: Student | null; loading: boolean }> { getStudentWithEnrollments(studentId: number): Observable<{ data: Student | null; loading: boolean }> {
return this.apollo return this.apollo
.watchQuery<StudentResult>({ .watchQuery<StudentResult>({
@ -46,6 +66,18 @@ export class EnrollmentService {
); );
} }
/**
* Retrieves subjects available for enrollment.
* Each subject includes availability status based on business rules:
* - Already enrolled: not available
* - Same professor: not available
* - Max subjects reached: not available
*
* Uses network-only to ensure fresh validation from server.
*
* @param studentId - The student's unique identifier
* @returns Observable with available subjects and their restrictions
*/
getAvailableSubjects(studentId: number): Observable<{ data: AvailableSubject[]; loading: boolean }> { getAvailableSubjects(studentId: number): Observable<{ data: AvailableSubject[]; loading: boolean }> {
return this.apollo return this.apollo
.watchQuery<AvailableSubjectsResult>({ .watchQuery<AvailableSubjectsResult>({
@ -61,6 +93,13 @@ export class EnrollmentService {
); );
} }
/**
* Retrieves classmates for each subject the student is enrolled in.
* Returns only names of other students (privacy-aware).
*
* @param studentId - The student's unique identifier
* @returns Observable with classmates grouped by subject
*/
getClassmates(studentId: number): Observable<{ data: Classmate[]; loading: boolean }> { getClassmates(studentId: number): Observable<{ data: Classmate[]; loading: boolean }> {
return this.apollo return this.apollo
.watchQuery<ClassmatesResult>({ .watchQuery<ClassmatesResult>({
@ -76,6 +115,18 @@ export class EnrollmentService {
); );
} }
/**
* Enrolls a student in a subject.
* Validates business rules on server:
* - Maximum 3 subjects per student (9 credits)
* - No two subjects with the same professor
*
* Automatically refreshes student data and available subjects on success.
*
* @param input - Enrollment request with studentId and subjectId
* @returns Observable with enrollment result or validation errors
* @throws GraphQL errors: MAX_ENROLLMENTS, SAME_PROFESSOR, DUPLICATE_ENROLLMENT
*/
enrollStudent(input: EnrollInput): Observable<EnrollmentPayload> { enrollStudent(input: EnrollInput): Observable<EnrollmentPayload> {
return this.apollo return this.apollo
.mutate<EnrollResult>({ .mutate<EnrollResult>({
@ -89,6 +140,16 @@ export class EnrollmentService {
.pipe(map(result => result.data?.enrollStudent ?? {})); .pipe(map(result => result.data?.enrollStudent ?? {}));
} }
/**
* Cancels a student's enrollment in a subject.
* Frees up a slot for another subject enrollment.
* Automatically refreshes student data and available subjects on success.
*
* @param enrollmentId - The enrollment's unique identifier
* @param studentId - The student's ID (needed for cache refresh)
* @returns Observable with success status or errors
* @throws GraphQL errors: ENROLLMENT_NOT_FOUND
*/
unenrollStudent(enrollmentId: number, studentId: number): Observable<DeletePayload> { unenrollStudent(enrollmentId: number, studentId: number): Observable<DeletePayload> {
return this.apollo return this.apollo
.mutate<UnenrollResult>({ .mutate<UnenrollResult>({

View File

@ -28,10 +28,27 @@ interface DeleteStudentResult {
deleteStudent: DeletePayload; deleteStudent: DeletePayload;
} }
/**
* Service for managing student CRUD operations via GraphQL.
* Provides reactive data access with Apollo Client caching.
*
* @example
* ```typescript
* this.studentService.getStudents().subscribe(({ data, loading }) => {
* this.students = data;
* });
* ```
*/
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class StudentService { export class StudentService {
private apollo = inject(Apollo); private apollo = inject(Apollo);
/**
* Retrieves all students from the server.
* Uses cache-and-network policy for optimal UX.
*
* @returns Observable emitting students array and loading state
*/
getStudents(): Observable<{ data: Student[]; loading: boolean }> { getStudents(): Observable<{ data: Student[]; loading: boolean }> {
return this.apollo return this.apollo
.watchQuery<StudentsQueryResult>({ .watchQuery<StudentsQueryResult>({
@ -46,6 +63,12 @@ export class StudentService {
); );
} }
/**
* Retrieves a single student by ID.
*
* @param id - The student's unique identifier
* @returns Observable emitting the student (or null if not found) and loading state
*/
getStudent(id: number): Observable<{ data: Student | null; loading: boolean }> { getStudent(id: number): Observable<{ data: Student | null; loading: boolean }> {
return this.apollo return this.apollo
.watchQuery<StudentQueryResult>({ .watchQuery<StudentQueryResult>({
@ -61,6 +84,14 @@ export class StudentService {
); );
} }
/**
* Creates a new student in the system.
* Automatically refreshes the students list cache on success.
*
* @param input - Student data (name, email)
* @returns Observable with created student or validation errors
* @throws GraphQL errors for validation failures (VALIDATION_ERROR)
*/
createStudent(input: CreateStudentInput): Observable<StudentPayload> { createStudent(input: CreateStudentInput): Observable<StudentPayload> {
return this.apollo return this.apollo
.mutate<CreateStudentResult>({ .mutate<CreateStudentResult>({
@ -71,6 +102,15 @@ export class StudentService {
.pipe(map(result => result.data?.createStudent ?? {})); .pipe(map(result => result.data?.createStudent ?? {}));
} }
/**
* Updates an existing student's information.
* Automatically refreshes the students list cache on success.
*
* @param id - The student's unique identifier
* @param input - Updated student data (name, email)
* @returns Observable with updated student or validation errors
* @throws GraphQL errors: STUDENT_NOT_FOUND, VALIDATION_ERROR
*/
updateStudent(id: number, input: UpdateStudentInput): Observable<StudentPayload> { updateStudent(id: number, input: UpdateStudentInput): Observable<StudentPayload> {
return this.apollo return this.apollo
.mutate<UpdateStudentResult>({ .mutate<UpdateStudentResult>({
@ -81,6 +121,15 @@ export class StudentService {
.pipe(map(result => result.data?.updateStudent ?? {})); .pipe(map(result => result.data?.updateStudent ?? {}));
} }
/**
* Deletes a student from the system.
* Also removes all associated enrollments (cascade delete).
* Automatically refreshes the students list cache on success.
*
* @param id - The student's unique identifier
* @returns Observable with success status or errors
* @throws GraphQL errors: STUDENT_NOT_FOUND
*/
deleteStudent(id: number): Observable<DeletePayload> { deleteStudent(id: number): Observable<DeletePayload> {
return this.apollo return this.apollo
.mutate<DeleteStudentResult>({ .mutate<DeleteStudentResult>({

View File

@ -5,6 +5,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { EnrollmentService, NotificationService, ErrorHandlerService } from '@core/services'; import { EnrollmentService, NotificationService, ErrorHandlerService } from '@core/services';
import { Student, AvailableSubject, Enrollment } from '@core/models'; import { Student, AvailableSubject, Enrollment } from '@core/models';
import { ENROLLMENT_LIMITS, RESTRICTION_TRANSLATIONS } from '@core/constants';
import { LoadingSpinnerComponent, EmptyStateComponent } from '@shared/index'; import { LoadingSpinnerComponent, EmptyStateComponent } from '@shared/index';
@Component({ @Component({
@ -54,7 +55,7 @@ import { LoadingSpinnerComponent, EmptyStateComponent } from '@shared/index';
</div> </div>
<div class="stat-value"> <div class="stat-value">
<span class="stat-current">{{ student()?.totalCredits || 0 }}</span> <span class="stat-current">{{ student()?.totalCredits || 0 }}</span>
<span class="stat-max">/9</span> <span class="stat-max">/{{ maxCredits }}</span>
</div> </div>
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-bar-fill" [style.width.%]="creditsProgress()"></div> <div class="progress-bar-fill" [style.width.%]="creditsProgress()"></div>
@ -67,7 +68,7 @@ import { LoadingSpinnerComponent, EmptyStateComponent } from '@shared/index';
</div> </div>
<div class="stat-value"> <div class="stat-value">
<span class="stat-current">{{ enrollments().length }}</span> <span class="stat-current">{{ enrollments().length }}</span>
<span class="stat-max">/3</span> <span class="stat-max">/{{ maxSubjects }}</span>
</div> </div>
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-bar-fill" [style.width.%]="subjectsProgress()"></div> <div class="progress-bar-fill" [style.width.%]="subjectsProgress()"></div>
@ -149,7 +150,7 @@ import { LoadingSpinnerComponent, EmptyStateComponent } from '@shared/index';
<button <button
class="btn btn-sm btn-primary" class="btn btn-sm btn-primary"
(click)="enroll(item)" (click)="enroll(item)"
[disabled]="!item.isAvailable || processingId() === item.id || enrollments().length >= 3" [disabled]="!item.isAvailable || processingId() === item.id || enrollments().length >= maxSubjects"
[matTooltip]="!item.isAvailable ? translateReason(item.unavailableReason || '') : 'Inscribir'" [matTooltip]="!item.isAvailable ? translateReason(item.unavailableReason || '') : 'Inscribir'"
data-testid="btn-enroll-subject" data-testid="btn-enroll-subject"
> >
@ -344,6 +345,10 @@ export class EnrollmentPageComponent implements OnInit {
private notification = inject(NotificationService); private notification = inject(NotificationService);
private errorHandler = inject(ErrorHandlerService); private errorHandler = inject(ErrorHandlerService);
/** Business rule constants exposed for template binding */
readonly maxCredits = ENROLLMENT_LIMITS.MAX_CREDITS;
readonly maxSubjects = ENROLLMENT_LIMITS.MAX_SUBJECTS;
student = signal<Student | null>(null); student = signal<Student | null>(null);
availableSubjects = signal<AvailableSubject[]>([]); availableSubjects = signal<AvailableSubject[]>([]);
loading = signal(true); loading = signal(true);
@ -351,8 +356,8 @@ export class EnrollmentPageComponent implements OnInit {
processingId = signal<number | null>(null); processingId = signal<number | null>(null);
enrollments = computed(() => this.student()?.enrollments ?? []); enrollments = computed(() => this.student()?.enrollments ?? []);
creditsProgress = computed(() => ((this.student()?.totalCredits ?? 0) / 9) * 100); creditsProgress = computed(() => ((this.student()?.totalCredits ?? 0) / this.maxCredits) * 100);
subjectsProgress = computed(() => (this.enrollments().length / 3) * 100); subjectsProgress = computed(() => (this.enrollments().length / this.maxSubjects) * 100);
ngOnInit(): void { ngOnInit(): void {
this.loadData(); this.loadData();
@ -429,13 +434,10 @@ export class EnrollmentPageComponent implements OnInit {
}); });
} }
private readonly reasonTranslations: Record<string, string> = { /**
'Already enrolled': 'Ya inscrito', * Translates API restriction messages to Spanish for display.
'Already have a subject with this professor': 'Ya tienes una materia con este profesor', */
'Maximum 3 subjects reached': 'Máximo 3 materias alcanzado',
};
translateReason(reason: string): string { translateReason(reason: string): string {
return this.reasonTranslations[reason] ?? reason; return RESTRICTION_TRANSLATIONS[reason] ?? reason;
} }
} }

View File

@ -1,11 +1,19 @@
import { Pipe, PipeTransform } from '@angular/core'; import { Pipe, PipeTransform } from '@angular/core';
import { ENROLLMENT_LIMITS } from '@core/constants';
/**
* Formats credits display with max value.
*
* @example
* {{ student.totalCredits | credits }} // "6/9 créditos"
* {{ 5 | credits:10 }} // "5/10 créditos"
*/
@Pipe({ @Pipe({
name: 'credits', name: 'credits',
standalone: true, standalone: true,
}) })
export class CreditsPipe implements PipeTransform { export class CreditsPipe implements PipeTransform {
transform(value: number, maxCredits = 9): string { transform(value: number, maxCredits: number = ENROLLMENT_LIMITS.MAX_CREDITS): string {
return `${value}/${maxCredits} créditos`; return `${value}/${maxCredits} créditos`;
} }
} }