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;
|
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>({
|
||||||
|
|
|
||||||
|
|
@ -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>({
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue