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.
This commit is contained in:
parent
41994ef8c5
commit
f70cbc42d8
|
|
@ -27,10 +27,30 @@ interface UnenrollResult {
|
|||
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' })
|
||||
export class EnrollmentService {
|
||||
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 }> {
|
||||
return this.apollo
|
||||
.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 }> {
|
||||
return this.apollo
|
||||
.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 }> {
|
||||
return this.apollo
|
||||
.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> {
|
||||
return this.apollo
|
||||
.mutate<EnrollResult>({
|
||||
|
|
@ -89,6 +140,16 @@ export class EnrollmentService {
|
|||
.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> {
|
||||
return this.apollo
|
||||
.mutate<UnenrollResult>({
|
||||
|
|
|
|||
|
|
@ -28,10 +28,27 @@ interface DeleteStudentResult {
|
|||
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' })
|
||||
export class StudentService {
|
||||
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 }> {
|
||||
return this.apollo
|
||||
.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 }> {
|
||||
return this.apollo
|
||||
.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> {
|
||||
return this.apollo
|
||||
.mutate<CreateStudentResult>({
|
||||
|
|
@ -71,6 +102,15 @@ export class StudentService {
|
|||
.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> {
|
||||
return this.apollo
|
||||
.mutate<UpdateStudentResult>({
|
||||
|
|
@ -81,6 +121,15 @@ export class StudentService {
|
|||
.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> {
|
||||
return this.apollo
|
||||
.mutate<DeleteStudentResult>({
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { MatButtonModule } from '@angular/material/button';
|
|||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { EnrollmentService, NotificationService, ErrorHandlerService } from '@core/services';
|
||||
import { Student, AvailableSubject, Enrollment } from '@core/models';
|
||||
import { ENROLLMENT_LIMITS, RESTRICTION_TRANSLATIONS } from '@core/constants';
|
||||
import { LoadingSpinnerComponent, EmptyStateComponent } from '@shared/index';
|
||||
|
||||
@Component({
|
||||
|
|
@ -54,7 +55,7 @@ import { LoadingSpinnerComponent, EmptyStateComponent } from '@shared/index';
|
|||
</div>
|
||||
<div class="stat-value">
|
||||
<span class="stat-current">{{ student()?.totalCredits || 0 }}</span>
|
||||
<span class="stat-max">/9</span>
|
||||
<span class="stat-max">/{{ maxCredits }}</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill" [style.width.%]="creditsProgress()"></div>
|
||||
|
|
@ -67,7 +68,7 @@ import { LoadingSpinnerComponent, EmptyStateComponent } from '@shared/index';
|
|||
</div>
|
||||
<div class="stat-value">
|
||||
<span class="stat-current">{{ enrollments().length }}</span>
|
||||
<span class="stat-max">/3</span>
|
||||
<span class="stat-max">/{{ maxSubjects }}</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill" [style.width.%]="subjectsProgress()"></div>
|
||||
|
|
@ -149,7 +150,7 @@ import { LoadingSpinnerComponent, EmptyStateComponent } from '@shared/index';
|
|||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
(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'"
|
||||
data-testid="btn-enroll-subject"
|
||||
>
|
||||
|
|
@ -344,6 +345,10 @@ export class EnrollmentPageComponent implements OnInit {
|
|||
private notification = inject(NotificationService);
|
||||
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);
|
||||
availableSubjects = signal<AvailableSubject[]>([]);
|
||||
loading = signal(true);
|
||||
|
|
@ -351,8 +356,8 @@ export class EnrollmentPageComponent implements OnInit {
|
|||
processingId = signal<number | null>(null);
|
||||
|
||||
enrollments = computed(() => this.student()?.enrollments ?? []);
|
||||
creditsProgress = computed(() => ((this.student()?.totalCredits ?? 0) / 9) * 100);
|
||||
subjectsProgress = computed(() => (this.enrollments().length / 3) * 100);
|
||||
creditsProgress = computed(() => ((this.student()?.totalCredits ?? 0) / this.maxCredits) * 100);
|
||||
subjectsProgress = computed(() => (this.enrollments().length / this.maxSubjects) * 100);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
|
|
@ -429,13 +434,10 @@ export class EnrollmentPageComponent implements OnInit {
|
|||
});
|
||||
}
|
||||
|
||||
private readonly reasonTranslations: Record<string, string> = {
|
||||
'Already enrolled': 'Ya inscrito',
|
||||
'Already have a subject with this professor': 'Ya tienes una materia con este profesor',
|
||||
'Maximum 3 subjects reached': 'Máximo 3 materias alcanzado',
|
||||
};
|
||||
|
||||
/**
|
||||
* Translates API restriction messages to Spanish for display.
|
||||
*/
|
||||
translateReason(reason: string): string {
|
||||
return this.reasonTranslations[reason] ?? reason;
|
||||
return RESTRICTION_TRANSLATIONS[reason] ?? reason;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
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({
|
||||
name: 'credits',
|
||||
standalone: true,
|
||||
})
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue