diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..4ca1fe66 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +# Slim context for docker/keycloak/Dockerfile (build context: Module-Management root) +Client/node_modules +Client/dist +Server/build +Server/.gradle +**/.git +.git diff --git a/Client/src/app/app.routes.ts b/Client/src/app/app.routes.ts index 7111c8b8..e37bfa2f 100644 --- a/Client/src/app/app.routes.ts +++ b/Client/src/app/app.routes.ts @@ -17,6 +17,8 @@ import { UsersPageComponent } from './pages/admin/users/users-page.component'; import { AllDegreeProgramsPageComponent } from './pages/admin/degree-programs/all-degree-programs-page.component'; import { DegreeProgramDetailsPageComponent } from './pages/admin/degree-programs/degree-program-details-page.component'; import { AllSpecializationsPageComponent } from './pages/admin/degree-program-specializations/all-specializations-page.component'; +import { ExaminationBoardDetailPageComponent } from './pages/admin/examination-boards/examination-board-detail-page.component'; +import { ExaminationBoardsPageComponent } from './pages/admin/examination-boards/examination-boards-page.component'; export const routes: Routes = [ { path: '', component: IndexComponent }, { @@ -55,6 +57,8 @@ export const routes: Routes = [ canActivate: [AuthGuard, AdminGuard], children: [ { path: 'users', component: UsersPageComponent }, + { path: 'examination-boards/:id', component: ExaminationBoardDetailPageComponent }, + { path: 'examination-boards', component: ExaminationBoardsPageComponent }, { path: 'degree-programs/specializations', component: AllSpecializationsPageComponent }, { path: 'degree-programs/:id', component: DegreeProgramDetailsPageComponent }, { path: 'degree-programs', component: AllDegreeProgramsPageComponent }, diff --git a/Client/src/app/components/breadcrumb/breadcrumb-labels.service.ts b/Client/src/app/components/breadcrumb/breadcrumb-labels.service.ts index 071fb279..5bd80f93 100644 --- a/Client/src/app/components/breadcrumb/breadcrumb-labels.service.ts +++ b/Client/src/app/components/breadcrumb/breadcrumb-labels.service.ts @@ -5,6 +5,8 @@ import { Injectable, signal } from '@angular/core'; export class BreadcrumbLabelsService { /** Degree program details page: program name. */ readonly degreeProgramName = signal(null); + /** Examination board detail page: board name. */ + readonly examinationBoardName = signal(null); /** Proposal/view segment: module title (e.g. from latestModuleVersion.titleEng). */ readonly proposalTitle = signal(null); /** Version segment: e.g. "Version 2" from moduleVersion.version. */ diff --git a/Client/src/app/components/breadcrumb/breadcrumb.component.ts b/Client/src/app/components/breadcrumb/breadcrumb.component.ts index eadb0f58..f9d11fd1 100644 --- a/Client/src/app/components/breadcrumb/breadcrumb.component.ts +++ b/Client/src/app/components/breadcrumb/breadcrumb.component.ts @@ -48,6 +48,20 @@ export class BreadcrumbComponent { return items; } + if (segments[1] === 'examination-boards') { + items.push({ label: 'Examination boards', routerLink: ['/admin/examination-boards'] }); + if (segments.length > 2 && segments[2] && segments[2] !== 'specializations') { + const boardId = segments[2]; + const label = + (this.breadcrumbLabels.examinationBoardName() ?? '').trim() || `Board ${boardId}`; + items.push({ + label, + routerLink: ['/admin/examination-boards', boardId] + }); + } + return items; + } + if (segments[1] === 'degree-programs') { items.push({ label: 'Degree Programs', routerLink: ['/admin/degree-programs'] }); if (segments.length <= 2) return items; diff --git a/Client/src/app/components/create-edit-base/create-edit-base.component.html b/Client/src/app/components/create-edit-base/create-edit-base.component.html index 14c3be04..e63fe5a3 100644 --- a/Client/src/app/components/create-edit-base/create-edit-base.component.html +++ b/Client/src/app/components/create-edit-base/create-edit-base.component.html @@ -220,7 +220,7 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' }
- + - @if (canRequestFullFeedback()) { + } + @if (canRequestExaminationBoardFeedback()) { @@ -817,12 +821,12 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' @if (isCreateMode()) { } @if (!isCreateMode() && currentStepIndex() < MODULE_EDIT_STEPS.length - 1) { @@ -830,15 +834,11 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' } @if (!isCreateMode()) { - {{ loading() ? 'Saving...' : 'Update proposal' }} + {{ 'Update proposal' }} } - - @if (error()) { - {{ error() }} - } diff --git a/Client/src/app/components/create-edit-base/create-edit-base.component.ts b/Client/src/app/components/create-edit-base/create-edit-base.component.ts index 17fbf382..7fb07ddd 100644 --- a/Client/src/app/components/create-edit-base/create-edit-base.component.ts +++ b/Client/src/app/components/create-edit-base/create-edit-base.component.ts @@ -15,8 +15,14 @@ import { DegreeProgramsControllerService } from '../../core/modules/openapi/api/ import { Location } from '@angular/common'; import { Router } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; +import { + filterCoordinatorFeedbacksForAssignments, + filterExaminationBoardMemberFeedbacks +} from '../module-edit-stepper/coordinator-feedback.util'; import { MODULE_EDIT_STEPS, StepperStatus } from '../module-edit-stepper/module-edit-steps.config'; +import { coordinatorFeedbackStepStatus, examinationBoardFeedbackStepStatus } from '../module-edit-stepper/module-version-stepper-status.util'; import { BreadcrumbLabelsService } from '../breadcrumb/breadcrumb-labels.service'; +import { MessageService } from 'primeng/api'; @Component({ template: '' @@ -29,13 +35,13 @@ export abstract class ProposalBaseComponent { protected proposalService = inject(ProposalControllerService); protected degreeProgramsService = inject(DegreeProgramsControllerService); protected breadcrumbLabels = inject(BreadcrumbLabelsService); + private readonly messageService = inject(MessageService); readonly MODULE_EDIT_STEPS = MODULE_EDIT_STEPS; proposalForm: FormGroup; loading = signal(false); loadingPrograms = signal(true); - error = signal(null); moduleVersionDto = signal(null); moduleVersionId: number | null = null; feedbacks = signal([]); @@ -49,11 +55,23 @@ export abstract class ProposalBaseComponent { /** Updated on form valueChanges so stepCompleted computed re-runs when user types. */ private formValueVersion = signal(0); + /** + * Per-step stepper state. Coordinator and examination-board steps use only + * `moduleVersionStatus()` (server workflow enum; aligned with proposal status on the server). + * Other steps depend on the form and/or assignments. + */ stepsStatuses = computed(() => { this.formValueVersion(); const form = this.proposalForm; const assignmentsList = this.assignments(); + const mvStatus = this.moduleVersionStatus(); return MODULE_EDIT_STEPS.map((step) => { + if (step.id === 'submit-coordinator-feedback') { + return coordinatorFeedbackStepStatus(mvStatus); + } + if (step.id === 'submit-examination-board-feedback') { + return examinationBoardFeedbackStepStatus(mvStatus); + } if (step.id === 'basic') { const allFieldsFilled = step.controlNames.every((name) => this.controlHasValue(form.get(name))); const hasCompleteAssignment = assignmentsList.some((a) => a.degreeProgramId != null && a.degreeProgramSpecializationId != null); @@ -63,21 +81,6 @@ export abstract class ProposalBaseComponent { return StepperStatus.Default; } } - if (step.id === 'submit-coordinator-feedback') { - const feedbacks = this.coordinatorFeedbacksForStep1().feedbacks; - if (feedbacks.length === 0) return StepperStatus.Default; - const pending = ModuleVersionViewFeedbackDTO.FeedbackStatusEnum.PendingFeedback; - const approved = ModuleVersionViewFeedbackDTO.FeedbackStatusEnum.Approved; - const rejected = ModuleVersionViewFeedbackDTO.FeedbackStatusEnum.Rejected; - if (feedbacks.some((fb) => fb.feedbackStatus === rejected)) return StepperStatus.Rejected; - if (feedbacks.some((fb) => (fb.feedbackStatus ?? pending) === pending)) return StepperStatus.Pending; - if (feedbacks.every((fb) => fb.feedbackStatus === approved)) return StepperStatus.Completed; - return StepperStatus.FeedbackGiven; - } - - if (step.id === 'submit-full-feedback') { - return StepperStatus.Default; - } return step.controlNames.every((name) => this.controlHasValue(form.get(name))) ? StepperStatus.Completed : StepperStatus.Default; }); }); @@ -102,39 +105,42 @@ export abstract class ProposalBaseComponent { /** Can submit for feedback (never submitted yet). */ canRequestCoordinatorsFeedback = computed(() => { const status = this.moduleVersionStatus(); - return status === 'PENDING_FIRST_SUBMISSION' && this.isFirstStepComplete(); + return status === 'WAITING_FOR_COORDINATORS_SUBMISSION' && this.isFirstStepComplete(); }); - /** Can submit for full feedback (second submission): PENDING_FULL_SUBMISSION, all steps done, all coordinator feedback accepted. */ - canRequestFullFeedback = computed(() => { - return ( - this.moduleVersionStatus() === 'PENDING_FULL_SUBMISSION' && - this.stepsStatuses() - .slice(0, 6) - .every((s) => s === StepperStatus.Completed) - ); + /** Steps 0–5 complete; workflow status (server-driven) implies coordinators are done. */ + canRequestExaminationBoardFeedback = computed(() => { + const statuses = this.stepsStatuses(); + const throughContentSteps = statuses.slice(0, 6); + return this.moduleVersionStatus() === 'WAITING_FOR_EXAMINATION_BOARD_SUBMISSION' && throughContentSteps.every((s) => s === StepperStatus.Completed); }); - /** Coordinator feedbacks for this version (for current assignments). From moduleVersionDto().feedbacks. */ - coordinatorFeedbacksForCurrentAssignmentsFromDto = computed(() => { - const dto = this.moduleVersionDto(); - const feedbacks = (dto as ModuleVersionViewDTO)?.feedbacks ?? []; - const coordinator = feedbacks.filter((f) => f.feedbackRole == null); - const specIds = new Set((dto as ModuleVersionViewDTO)?.degreeProgramAssignments?.map((a) => a.degreeProgramSpecializationId).filter((id): id is number => id != null) ?? []); - if (specIds.size === 0) return coordinator; - return coordinator.filter((f) => f.degreeProgramSpecializationId != null && specIds.has(f.degreeProgramSpecializationId)); + /** + * Coordinator feedback for step 1: current module version DTO if any rows match; otherwise + * {@code feedbacks()} from the previous-version API (latest draft may not list rows yet). + */ + coordinatorFeedbacksForStep1 = computed(() => { + const dto = this.moduleVersionDto() as ModuleVersionViewDTO | null; + const fromDto = filterCoordinatorFeedbacksForAssignments(dto?.feedbacks ?? [], dto); + if (fromDto.length > 0) { + return { feedbacks: fromDto, fromPrevious: false }; + } + const fromPrev = filterCoordinatorFeedbacksForAssignments(this.feedbacks() ?? [], dto); + return { feedbacks: fromPrev, fromPrevious: true }; }); - /** Coordinator feedbacks to show in step 1: current version if any, otherwise previous version (feedbacks() from API). */ - coordinatorFeedbacksForStep1 = computed(() => { - const fromDto = this.coordinatorFeedbacksForCurrentAssignmentsFromDto(); - if (fromDto.length > 0) return { feedbacks: fromDto, fromPrevious: false }; - const prev = this.feedbacks() ?? []; - const coordinator = prev.filter((f) => f.feedbackRole == null); + /** + * Examination-board member feedback for step 6: current DTO if listed there; else + * {@code feedbacks()} from previous-version API (same pattern as coordinator step 1). + */ + examinationBoardMemberFeedbacksForStep6 = computed(() => { const dto = this.moduleVersionDto() as ModuleVersionViewDTO | null; - const specIds = new Set(dto?.degreeProgramAssignments?.map((a) => a.degreeProgramSpecializationId).filter((id): id is number => id != null) ?? []); - const filtered = specIds.size === 0 ? coordinator : coordinator.filter((f) => f.degreeProgramSpecializationId != null && specIds.has(f.degreeProgramSpecializationId)); - return { feedbacks: filtered, fromPrevious: true }; + const fromDto = filterExaminationBoardMemberFeedbacks(dto?.feedbacks ?? []); + if (fromDto.length > 0) { + return { feedbacks: fromDto, fromPrevious: false }; + } + const fromPrev = filterExaminationBoardMemberFeedbacks(this.feedbacks() ?? []); + return { feedbacks: fromPrev, fromPrevious: true }; }); showPrompt: { [key: string]: boolean } = { @@ -244,56 +250,92 @@ export abstract class ProposalBaseComponent { return program?.degreeProgramSpecializations ?? []; } + /** Shows a PrimeNG toast (global {@code p-toast} in {@code AppComponent}). */ + protected showErrorAsToast(err: unknown, fallbackMessage?: string): void { + const detail = this.resolveErrorMessage(err, fallbackMessage); + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail, + life: 8000 + }); + } + + private resolveErrorMessage(err: unknown, fallbackMessage?: string): string { + if (err instanceof HttpErrorResponse) { + const fromBody = this.messageFromHttpBody(err.error); + if (fromBody) return fromBody; + if (err.status === 0) { + return fallbackMessage ?? 'Network error. Check your connection.'; + } + return fallbackMessage ?? err.message ?? `Request failed (${err.status}).`; + } + const direct = this.messageFromHttpBody(err); + if (direct) return direct; + return fallbackMessage ?? 'Something went wrong.'; + } + + private messageFromHttpBody(body: unknown): string | null { + if (body == null || body === '') return null; + if (typeof body === 'string') { + const t = body.trim(); + return t.length > 0 ? t : null; + } + if (typeof body === 'object' && body !== null) { + const o = body as Record; + for (const key of ['message', 'error', 'detail', 'title']) { + const v = o[key]; + if (typeof v === 'string' && v.trim()) return v.trim(); + } + } + return null; + } + onProgramChange(rowIndex: number) { this.setAssignmentSpecialization(rowIndex, null); } - requestCoordinatorsFeedback(): void { + requestExaminationBoardFeedback(): void { const dto = this.moduleVersionDto(); const proposalId = dto && 'proposalId' in dto ? (dto as ModuleVersionViewDTO).proposalId : null; if (proposalId == null) return; this.loading.set(true); - this.error.set(null); - this.proposalService.requestCoordinatorsFeedback(proposalId).subscribe({ + this.proposalService.requestExaminationBoardFeedback(proposalId).subscribe({ next: (response: ProposalViewDTO) => { this.moduleVersionDto.set(response); - // When backend created a new version (immutable versioning), switch to editing the new version const newId = response?.latestModuleVersion?.moduleVersionId; if (newId != null && newId !== this.moduleVersionId) { this.moduleVersionService.getPreviousModuleVersionFeedback(newId).subscribe({ next: (feedbacks) => this.feedbacks.set([...feedbacks]), - error: (err: HttpErrorResponse) => this.error.set(err.error) + error: (err: HttpErrorResponse) => this.showErrorAsToast(err) }); - this.moduleVersionId = newId; this.breadcrumbLabels.versionLabel.set(response?.latestVersion != null ? `Version ${response.latestVersion}` : null); this.router.navigate(['/proposals', proposalId, 'version', newId, 'edit'], { replaceUrl: true }); } }, error: (err: HttpErrorResponse) => { - this.error.set(err.error?.message ?? err.error ?? 'Failed to submit'); + this.showErrorAsToast(err, 'Failed to submit for examination board feedback'); this.loading.set(false); }, complete: () => this.loading.set(false) }); } - requestFullFeedback(): void { + requestCoordinatorsFeedback(): void { const dto = this.moduleVersionDto(); const proposalId = dto && 'proposalId' in dto ? (dto as ModuleVersionViewDTO).proposalId : null; if (proposalId == null) return; this.loading.set(true); - this.error.set(null); - this.proposalService.requestFullFeedback(proposalId).subscribe({ + this.proposalService.requestCoordinatorsFeedback(proposalId).subscribe({ next: (response: ProposalViewDTO) => { this.moduleVersionDto.set(response); // When backend created a new version (immutable versioning), switch to editing the new version const newId = response?.latestModuleVersion?.moduleVersionId; if (newId != null && newId !== this.moduleVersionId) { - // Refresh feedbacks so the next step reflects statuses from the freezed version. this.moduleVersionService.getPreviousModuleVersionFeedback(newId).subscribe({ next: (feedbacks) => this.feedbacks.set([...feedbacks]), - error: (err: HttpErrorResponse) => this.error.set(err.error) + error: (err: HttpErrorResponse) => this.showErrorAsToast(err) }); this.moduleVersionId = newId; @@ -302,7 +344,7 @@ export abstract class ProposalBaseComponent { } }, error: (err: HttpErrorResponse) => { - this.error.set(err.error?.message ?? err.error ?? 'Failed to submit for full feedback'); + this.showErrorAsToast(err, 'Failed to submit'); this.loading.set(false); }, complete: () => this.loading.set(false) @@ -329,7 +371,7 @@ export abstract class ProposalBaseComponent { next: (response: CompletionServiceResponseDTO) => { this.proposalForm.patchValue({ contentEng: response.responseData }); }, - error: (err: HttpErrorResponse) => console.log(err.error), + error: (err: HttpErrorResponse) => this.showErrorAsToast(err, 'Failed to generate content'), complete: () => this.loading.set(false) }); } @@ -341,7 +383,7 @@ export abstract class ProposalBaseComponent { next: (response: CompletionServiceResponseDTO) => { this.proposalForm.patchValue({ examinationAchievementsEng: response.responseData }); }, - error: (err: HttpErrorResponse) => console.log(err.error), + error: (err: HttpErrorResponse) => this.showErrorAsToast(err, 'Failed to generate examination achievements'), complete: () => this.loading.set(false) }); } @@ -353,7 +395,7 @@ export abstract class ProposalBaseComponent { next: (response: CompletionServiceResponseDTO) => { this.proposalForm.patchValue({ learningOutcomesEng: response.responseData }); }, - error: (err: HttpErrorResponse) => console.log(err.error), + error: (err: HttpErrorResponse) => this.showErrorAsToast(err, 'Failed to generate learning outcomes'), complete: () => this.loading.set(false) }); } @@ -365,7 +407,7 @@ export abstract class ProposalBaseComponent { next: (response: CompletionServiceResponseDTO) => { this.proposalForm.patchValue({ teachingMethodsEng: response.responseData }); }, - error: (err: HttpErrorResponse) => console.log(err.error), + error: (err: HttpErrorResponse) => this.showErrorAsToast(err, 'Failed to generate teaching methods'), complete: () => this.loading.set(false) }); } diff --git a/Client/src/app/components/header/header.component.html b/Client/src/app/components/header/header.component.html index b2a89521..b552d2f3 100644 --- a/Client/src/app/components/header/header.component.html +++ b/Client/src/app/components/header/header.component.html @@ -13,9 +13,7 @@ CIT Module Management -
- - - @if (user() !== undefined) { - - - - } @else { - - } +
diff --git a/Client/src/app/components/header/header.component.ts b/Client/src/app/components/header/header.component.ts index b552e0af..ab342555 100644 --- a/Client/src/app/components/header/header.component.ts +++ b/Client/src/app/components/header/header.component.ts @@ -4,16 +4,14 @@ import { SecurityStore } from '../../core/security/security-store.service'; import { ThemeService } from '../../core/theme/theme.service'; import { SidebarService } from '../side-bar/sidebar.service'; import { ButtonModule } from 'primeng/button'; -import { AvatarModule } from 'primeng/avatar'; -import { MenuModule } from 'primeng/menu'; import { TooltipModule } from 'primeng/tooltip'; -import { MenuItem } from 'primeng/api'; +import { SignInComponent } from '../sign-in/sign-in.component'; @Component({ selector: 'app-header', templateUrl: './header.component.html', standalone: true, - imports: [RouterLink, ButtonModule, AvatarModule, MenuModule, TooltipModule] + imports: [RouterLink, ButtonModule, TooltipModule, SignInComponent] }) export class HeaderComponent { securityStore = inject(SecurityStore); @@ -23,31 +21,7 @@ export class HeaderComponent { user = this.securityStore.user; isDarkMode = this.themeService.isDarkMode; - menuItems: MenuItem[] = [ - { - label: 'Settings', - icon: 'pi pi-cog', - routerLink: '/account' - }, - { - separator: true - }, - { - label: 'Sign Out', - icon: 'pi pi-sign-out', - command: () => this.signOut() - } - ]; - toggleTheme() { this.themeService.toggleTheme(); } - - signIn() { - this.securityStore.signIn(); - } - - signOut() { - this.securityStore.signOut(); - } } diff --git a/Client/src/app/components/module-edit-stepper/coordinator-feedback.util.ts b/Client/src/app/components/module-edit-stepper/coordinator-feedback.util.ts new file mode 100644 index 00000000..83e32300 --- /dev/null +++ b/Client/src/app/components/module-edit-stepper/coordinator-feedback.util.ts @@ -0,0 +1,45 @@ +import { ModuleVersionViewDTO, ModuleVersionViewFeedbackDTO } from '../../core/modules/openapi'; + +/** + * Program / area coordinator feedback rows (assignee + specialization). Excludes + * examination-board member rows, which also have {@code requiredRole == null}. + */ +export function isCoordinatorAssignmentFeedback(f: ModuleVersionViewFeedbackDTO): boolean { + return ( + f.requiredRole == null && + f.examinationBoardId == null && + f.degreeProgramSpecializationId != null + ); +} + +/** + * Coordinator slice from {@code feedbacks}, scoped to specialization ids on {@code dto} + * (when the module version has assignments). If there are no assignment ids, returns all + * coordinator-assignment rows from the list. + */ +export function filterCoordinatorFeedbacksForAssignments( + feedbacks: ModuleVersionViewFeedbackDTO[], + dto: ModuleVersionViewDTO | null +): ModuleVersionViewFeedbackDTO[] { + const coordinator = feedbacks.filter(isCoordinatorAssignmentFeedback); + const specIds = new Set( + (dto?.degreeProgramAssignments ?? []) + .map((a) => a.degreeProgramSpecializationId) + .filter((id): id is number => id != null) + ); + if (specIds.size === 0) { + return coordinator; + } + return coordinator.filter((f) => f.degreeProgramSpecializationId != null && specIds.has(f.degreeProgramSpecializationId)); +} + +/** Examination-board member rows (assignee-based; no specialization on the feedback row). */ +export function isExaminationBoardMemberFeedback(f: ModuleVersionViewFeedbackDTO): boolean { + return f.requiredRole == null && f.examinationBoardId != null && f.degreeProgramSpecializationId == null; +} + +export function filterExaminationBoardMemberFeedbacks( + feedbacks: ModuleVersionViewFeedbackDTO[] +): ModuleVersionViewFeedbackDTO[] { + return feedbacks.filter(isExaminationBoardMemberFeedback); +} diff --git a/Client/src/app/components/module-edit-stepper/module-edit-steps.config.ts b/Client/src/app/components/module-edit-stepper/module-edit-steps.config.ts index 5ce82ac2..6798ff3b 100644 --- a/Client/src/app/components/module-edit-stepper/module-edit-steps.config.ts +++ b/Client/src/app/components/module-edit-stepper/module-edit-steps.config.ts @@ -24,7 +24,7 @@ export const MODULE_EDIT_STEPS: ModuleEditStepConfig[] = [ controlNames: [ 'titleEng', 'titleDe', - // 'bulletPoints', + 'bulletPoints', 'credits', 'frequencyEng', 'hoursLecture', @@ -33,7 +33,7 @@ export const MODULE_EDIT_STEPS: ModuleEditStepConfig[] = [ 'hoursSeminar', 'firstSemesterAvailable', 'successorModuleName', - // 'levelEng', + 'levelEng', 'languageEng' ], requiredControlNames: ['titleEng'] @@ -64,8 +64,8 @@ export const MODULE_EDIT_STEPS: ModuleEditStepConfig[] = [ controlNames: ['mediaEng', 'literatureEng', 'responsiblesEng', 'lvSwsLecturerEng'] }, { - id: 'submit-full-feedback', - title: 'Submit for full feedback', + id: 'submit-examination-board-feedback', + title: 'Submit for examination board feedback', controlNames: [] } ]; diff --git a/Client/src/app/components/module-edit-stepper/module-version-stepper-status.util.ts b/Client/src/app/components/module-edit-stepper/module-version-stepper-status.util.ts new file mode 100644 index 00000000..8eb6af41 --- /dev/null +++ b/Client/src/app/components/module-edit-stepper/module-version-stepper-status.util.ts @@ -0,0 +1,70 @@ +import { ModuleVersionViewDTO } from '../../core/modules/openapi'; +import { StepperStatus } from './module-edit-steps.config'; + +type Status = ModuleVersionViewDTO.StatusEnum; + +/** + * Maps module-version workflow status to the coordinator-feedback stepper segment. + * Uses only {@link ModuleVersionViewDTO.status} (same enum values as proposal workflow when synced). + */ +export function coordinatorFeedbackStepStatus(status: Status | undefined): StepperStatus { + if (!status) return StepperStatus.Default; + switch (status) { + case 'WAITING_FOR_COORDINATORS_SUBMISSION': + return StepperStatus.Default; + case 'PENDING_COORDINATORS_FEEDBACK': + return StepperStatus.Pending; + case 'COORDINATORS_FEEDBACK_GIVEN': + return StepperStatus.FeedbackGiven; + case 'REQUIRES_REVIEW': + return StepperStatus.FeedbackGiven; + case 'WAITING_FOR_EXAMINATION_BOARD_SUBMISSION': + case 'PENDING_EXAMINATION_BOARD_FEEDBACK': + case 'EXAMINATION_BOARD_FEEDBACK_GIVEN': + case 'WAITING_FOR_QUALITY_MANAGEMENT_SUBMISSION': + case 'PENDING_QUALITY_MANAGEMENT_FEEDBACK': + case 'ACCEPTED': + return StepperStatus.Completed; + case 'REJECTED_AT_COORDINATORS_FEEDBACK': + return StepperStatus.Rejected; + case 'REJECTED_AT_EXAMINATION_BOARD_FEEDBACK': + return StepperStatus.Completed; + case 'CANCELLED': + return StepperStatus.Default; + default: + return StepperStatus.Default; + } +} + +/** + * Maps module-version workflow status to the examination-board feedback stepper segment. + * Uses only {@link ModuleVersionViewDTO.status}. + */ +export function examinationBoardFeedbackStepStatus(status: Status | undefined): StepperStatus { + if (!status) return StepperStatus.Default; + switch (status) { + case 'WAITING_FOR_COORDINATORS_SUBMISSION': + case 'PENDING_COORDINATORS_FEEDBACK': + case 'COORDINATORS_FEEDBACK_GIVEN': + case 'REQUIRES_REVIEW': + return StepperStatus.Default; + case 'WAITING_FOR_EXAMINATION_BOARD_SUBMISSION': + return StepperStatus.Default; + case 'PENDING_EXAMINATION_BOARD_FEEDBACK': + return StepperStatus.Pending; + case 'EXAMINATION_BOARD_FEEDBACK_GIVEN': + return StepperStatus.FeedbackGiven; + case 'WAITING_FOR_QUALITY_MANAGEMENT_SUBMISSION': + case 'PENDING_QUALITY_MANAGEMENT_FEEDBACK': + case 'ACCEPTED': + return StepperStatus.Completed; + case 'REJECTED_AT_EXAMINATION_BOARD_FEEDBACK': + return StepperStatus.Rejected; + case 'REJECTED_AT_COORDINATORS_FEEDBACK': + return StepperStatus.Default; + case 'CANCELLED': + return StepperStatus.Default; + default: + return StepperStatus.Default; + } +} diff --git a/Client/src/app/components/side-bar/side-bar.component.html b/Client/src/app/components/side-bar/side-bar.component.html index f7007f4f..4aba109a 100644 --- a/Client/src/app/components/side-bar/side-bar.component.html +++ b/Client/src/app/components/side-bar/side-bar.component.html @@ -17,6 +17,14 @@ [style]="{ width: '100%' }" styleClass="sidebar-btn justify-start" /> + } @if (isProfessor()) { + + +} @else { + + + + +} diff --git a/Client/src/app/components/sign-in/sign-in.component.ts b/Client/src/app/components/sign-in/sign-in.component.ts new file mode 100644 index 00000000..d92b434a --- /dev/null +++ b/Client/src/app/components/sign-in/sign-in.component.ts @@ -0,0 +1,35 @@ +import { Component, inject } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { ButtonModule } from 'primeng/button'; +import { ButtonGroupModule } from 'primeng/buttongroup'; +import { MenuModule } from 'primeng/menu'; +import { TooltipModule } from 'primeng/tooltip'; +import { MenuItem } from 'primeng/api'; +import { SecurityStore } from '../../core/security/security-store.service'; + +@Component({ + selector: 'app-sign-in', + standalone: true, + imports: [RouterModule, ButtonModule, ButtonGroupModule, MenuModule, TooltipModule], + templateUrl: './sign-in.component.html' +}) +export class SignInComponent { + readonly securityStore = inject(SecurityStore); + readonly user = this.securityStore.user; + + menuItems: MenuItem[] = [ + { + label: 'Settings', + icon: 'pi pi-cog', + routerLink: '/account' + }, + { + separator: true + }, + { + label: 'Sign Out', + icon: 'pi pi-sign-out', + command: () => this.securityStore.signOut() + } + ]; +} diff --git a/Client/src/app/components/users-select/users-select.component.ts b/Client/src/app/components/users-select/users-select.component.ts index 3630113f..db50e8a4 100644 --- a/Client/src/app/components/users-select/users-select.component.ts +++ b/Client/src/app/components/users-select/users-select.component.ts @@ -25,6 +25,8 @@ export class UsersSelectComponent implements ControlValueAccessor { styleClass = input(''); /** When the selected user is not yet in the loaded list, pass this so they are displayed like other options. */ selectedUser = input(null); + /** User IDs to omit from the list (e.g. users already assigned elsewhere). */ + excludeUserIds = input([]); constructor() { this.loadUsers(); @@ -48,12 +50,15 @@ export class UsersSelectComponent implements ControlValueAccessor { } userOptions = computed(() => { - const list = this.users().map((u) => ({ - label: this.formatUserLabel(u), - value: u.userId - })); + const exclude = new Set(this.excludeUserIds().filter((id): id is string => !!id)); + const list = this.users() + .filter((u) => u.userId && !exclude.has(u.userId)) + .map((u) => ({ + label: this.formatUserLabel(u), + value: u.userId + })); const current = this.value(); - if (current && !list.some((o) => o.value === current)) { + if (current && !exclude.has(current) && !list.some((o) => o.value === current)) { const u = this.selectedUser(); const label = u ? this.formatUserLabel(u) : current; return [{ label, value: current }, ...list]; diff --git a/Client/src/app/core/modules/openapi/.openapi-generator/FILES b/Client/src/app/core/modules/openapi/.openapi-generator/FILES index ddd04dbb..2e877374 100644 --- a/Client/src/app/core/modules/openapi/.openapi-generator/FILES +++ b/Client/src/app/core/modules/openapi/.openapi-generator/FILES @@ -9,6 +9,8 @@ api/degree-program-specializations-controller.service.ts api/degree-program-specializations-controller.serviceInterface.ts api/degree-programs-controller.service.ts api/degree-programs-controller.serviceInterface.ts +api/examination-board-controller.service.ts +api/examination-board-controller.serviceInterface.ts api/feedback-controller.service.ts api/feedback-controller.serviceInterface.ts api/module-version-controller.service.ts @@ -26,8 +28,11 @@ model/completion-service-request-dto.ts model/completion-service-response-dto.ts model/create-degree-program-dto.ts model/create-degree-program-specialization-dto.ts +model/create-examination-board-dto.ts model/degree-program-dto.ts model/degree-program-specialization-dto.ts +model/examination-board-dto.ts +model/examination-board-summary-dto.ts model/feedback-compact-dto.ts model/feedback-dto.ts model/feedback-list-item-dto.ts @@ -47,6 +52,7 @@ model/responsible-user-dto.ts model/similar-module-dto.ts model/update-degree-program-dto.ts model/update-degree-program-specialization-dto.ts +model/update-examination-board-dto.ts model/update-user-role-dto.ts model/user-dto.ts model/user.ts diff --git a/Client/src/app/core/modules/openapi/api/api.ts b/Client/src/app/core/modules/openapi/api/api.ts index 80159f7c..1f2dad9c 100644 --- a/Client/src/app/core/modules/openapi/api/api.ts +++ b/Client/src/app/core/modules/openapi/api/api.ts @@ -7,6 +7,9 @@ export * from './degree-program-specializations-controller.serviceInterface'; export * from './degree-programs-controller.service'; import { DegreeProgramsControllerService } from './degree-programs-controller.service'; export * from './degree-programs-controller.serviceInterface'; +export * from './examination-board-controller.service'; +import { ExaminationBoardControllerService } from './examination-board-controller.service'; +export * from './examination-board-controller.serviceInterface'; export * from './feedback-controller.service'; import { FeedbackControllerService } from './feedback-controller.service'; export * from './feedback-controller.serviceInterface'; @@ -19,4 +22,4 @@ export * from './proposal-controller.serviceInterface'; export * from './user-controller.service'; import { UserControllerService } from './user-controller.service'; export * from './user-controller.serviceInterface'; -export const APIS = [AdminUserControllerService, DegreeProgramSpecializationsControllerService, DegreeProgramsControllerService, FeedbackControllerService, ModuleVersionControllerService, ProposalControllerService, UserControllerService]; +export const APIS = [AdminUserControllerService, DegreeProgramSpecializationsControllerService, DegreeProgramsControllerService, ExaminationBoardControllerService, FeedbackControllerService, ModuleVersionControllerService, ProposalControllerService, UserControllerService]; diff --git a/Client/src/app/core/modules/openapi/api/examination-board-controller.service.ts b/Client/src/app/core/modules/openapi/api/examination-board-controller.service.ts new file mode 100644 index 00000000..54c2db60 --- /dev/null +++ b/Client/src/app/core/modules/openapi/api/examination-board-controller.service.ts @@ -0,0 +1,436 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +/* tslint:disable:no-unused-variable member-ordering */ + +import { Inject, Injectable, Optional } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams, + HttpResponse, HttpEvent, HttpParameterCodec, HttpContext + } from '@angular/common/http'; +import { CustomHttpParameterCodec } from '../encoder'; +import { Observable } from 'rxjs'; + +// @ts-ignore +import { CreateExaminationBoardDTO } from '../model/create-examination-board-dto'; +// @ts-ignore +import { ExaminationBoardDTO } from '../model/examination-board-dto'; +// @ts-ignore +import { ExaminationBoardSummaryDTO } from '../model/examination-board-summary-dto'; +// @ts-ignore +import { UpdateExaminationBoardDTO } from '../model/update-examination-board-dto'; + +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; +import { Configuration } from '../configuration'; +import { + ExaminationBoardControllerServiceInterface +} from './examination-board-controller.serviceInterface'; + + + +@Injectable({ + providedIn: 'root' +}) +export class ExaminationBoardControllerService implements ExaminationBoardControllerServiceInterface { + + protected basePath = 'http://localhost:8080'; + public defaultHeaders = new HttpHeaders(); + public configuration = new Configuration(); + public encoder: HttpParameterCodec; + + constructor(protected httpClient: HttpClient, @Optional()@Inject(BASE_PATH) basePath: string|string[], @Optional() configuration: Configuration) { + if (configuration) { + this.configuration = configuration; + } + if (typeof this.configuration.basePath !== 'string') { + const firstBasePath = Array.isArray(basePath) ? basePath[0] : undefined; + if (firstBasePath != undefined) { + basePath = firstBasePath; + } + + if (typeof basePath !== 'string') { + basePath = this.basePath; + } + this.configuration.basePath = basePath; + } + this.encoder = this.configuration.encoder || new CustomHttpParameterCodec(); + } + + + // @ts-ignore + private addToHttpParams(httpParams: HttpParams, value: any, key?: string): HttpParams { + if (typeof value === "object" && value instanceof Date === false) { + httpParams = this.addToHttpParamsRecursive(httpParams, value); + } else { + httpParams = this.addToHttpParamsRecursive(httpParams, value, key); + } + return httpParams; + } + + private addToHttpParamsRecursive(httpParams: HttpParams, value?: any, key?: string): HttpParams { + if (value == null) { + return httpParams; + } + + if (typeof value === "object") { + if (Array.isArray(value)) { + (value as any[]).forEach( elem => httpParams = this.addToHttpParamsRecursive(httpParams, elem, key)); + } else if (value instanceof Date) { + if (key != null) { + httpParams = httpParams.append(key, (value as Date).toISOString().substring(0, 10)); + } else { + throw Error("key may not be null if value is Date"); + } + } else { + Object.keys(value).forEach( k => httpParams = this.addToHttpParamsRecursive( + httpParams, value[k], key != null ? `${key}.${k}` : k)); + } + } else if (key != null) { + httpParams = httpParams.append(key, value); + } else { + throw Error("key may not be null if value is not object or array"); + } + return httpParams; + } + + /** + * @param createExaminationBoardDTO + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public createExaminationBoard(createExaminationBoardDTO: CreateExaminationBoardDTO, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public createExaminationBoard(createExaminationBoardDTO: CreateExaminationBoardDTO, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public createExaminationBoard(createExaminationBoardDTO: CreateExaminationBoardDTO, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public createExaminationBoard(createExaminationBoardDTO: CreateExaminationBoardDTO, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (createExaminationBoardDTO === null || createExaminationBoardDTO === undefined) { + throw new Error('Required parameter createExaminationBoardDTO was null or undefined when calling createExaminationBoard.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + // to determine the Content-Type header + const consumes: string[] = [ + 'application/json' + ]; + const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes); + if (httpContentTypeSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected); + } + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/admin/examination-boards`; + return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + body: createExaminationBoardDTO, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * @param id + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public deleteExaminationBoard(id: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable; + public deleteExaminationBoard(id: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable>; + public deleteExaminationBoard(id: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable>; + public deleteExaminationBoard(id: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable { + if (id === null || id === undefined) { + throw new Error('Required parameter id was null or undefined when calling deleteExaminationBoard.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/admin/examination-boards/${this.configuration.encodeParam({name: "id", value: id, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; + return this.httpClient.request('delete', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getAllExaminationBoards(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getAllExaminationBoards(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getAllExaminationBoards(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getAllExaminationBoards(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/admin/examination-boards`; + return this.httpClient.request>('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * @param id + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getExaminationBoard(id: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getExaminationBoard(id: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getExaminationBoard(id: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getExaminationBoard(id: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (id === null || id === undefined) { + throw new Error('Required parameter id was null or undefined when calling getExaminationBoard.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/admin/examination-boards/${this.configuration.encodeParam({name: "id", value: id, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * @param id + * @param updateExaminationBoardDTO + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public updateExaminationBoard(id: number, updateExaminationBoardDTO: UpdateExaminationBoardDTO, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public updateExaminationBoard(id: number, updateExaminationBoardDTO: UpdateExaminationBoardDTO, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public updateExaminationBoard(id: number, updateExaminationBoardDTO: UpdateExaminationBoardDTO, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public updateExaminationBoard(id: number, updateExaminationBoardDTO: UpdateExaminationBoardDTO, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (id === null || id === undefined) { + throw new Error('Required parameter id was null or undefined when calling updateExaminationBoard.'); + } + if (updateExaminationBoardDTO === null || updateExaminationBoardDTO === undefined) { + throw new Error('Required parameter updateExaminationBoardDTO was null or undefined when calling updateExaminationBoard.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + // to determine the Content-Type header + const consumes: string[] = [ + 'application/json' + ]; + const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes); + if (httpContentTypeSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected); + } + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/admin/examination-boards/${this.configuration.encodeParam({name: "id", value: id, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; + return this.httpClient.request('put', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + body: updateExaminationBoardDTO, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + +} diff --git a/Client/src/app/core/modules/openapi/api/examination-board-controller.serviceInterface.ts b/Client/src/app/core/modules/openapi/api/examination-board-controller.serviceInterface.ts new file mode 100644 index 00000000..8d3e2f35 --- /dev/null +++ b/Client/src/app/core/modules/openapi/api/examination-board-controller.serviceInterface.ts @@ -0,0 +1,63 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { HttpHeaders } from '@angular/common/http'; + +import { Observable } from 'rxjs'; + +import { CreateExaminationBoardDTO } from '../model/models'; +import { ExaminationBoardDTO } from '../model/models'; +import { ExaminationBoardSummaryDTO } from '../model/models'; +import { UpdateExaminationBoardDTO } from '../model/models'; + + +import { Configuration } from '../configuration'; + + + +export interface ExaminationBoardControllerServiceInterface { + defaultHeaders: HttpHeaders; + configuration: Configuration; + + /** + * + * + * @param createExaminationBoardDTO + */ + createExaminationBoard(createExaminationBoardDTO: CreateExaminationBoardDTO, extraHttpRequestParams?: any): Observable; + + /** + * + * + * @param id + */ + deleteExaminationBoard(id: number, extraHttpRequestParams?: any): Observable<{}>; + + /** + * + * + */ + getAllExaminationBoards(extraHttpRequestParams?: any): Observable>; + + /** + * + * + * @param id + */ + getExaminationBoard(id: number, extraHttpRequestParams?: any): Observable; + + /** + * + * + * @param id + * @param updateExaminationBoardDTO + */ + updateExaminationBoard(id: number, updateExaminationBoardDTO: UpdateExaminationBoardDTO, extraHttpRequestParams?: any): Observable; + +} diff --git a/Client/src/app/core/modules/openapi/api/proposal-controller.service.ts b/Client/src/app/core/modules/openapi/api/proposal-controller.service.ts index 5da30211..f5426ee3 100644 --- a/Client/src/app/core/modules/openapi/api/proposal-controller.service.ts +++ b/Client/src/app/core/modules/openapi/api/proposal-controller.service.ts @@ -423,12 +423,12 @@ export class ProposalControllerService implements ProposalControllerServiceInter * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public requestFullFeedback(proposalId: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; - public requestFullFeedback(proposalId: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public requestFullFeedback(proposalId: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public requestFullFeedback(proposalId: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + public requestExaminationBoardFeedback(proposalId: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public requestExaminationBoardFeedback(proposalId: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public requestExaminationBoardFeedback(proposalId: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public requestExaminationBoardFeedback(proposalId: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { if (proposalId === null || proposalId === undefined) { - throw new Error('Required parameter proposalId was null or undefined when calling requestFullFeedback.'); + throw new Error('Required parameter proposalId was null or undefined when calling requestExaminationBoardFeedback.'); } let localVarHeaders = this.defaultHeaders; @@ -467,7 +467,7 @@ export class ProposalControllerService implements ProposalControllerServiceInter } } - let localVarPath = `/api/proposals/request-full-feedback/${this.configuration.encodeParam({name: "proposalId", value: proposalId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; + let localVarPath = `/api/proposals/request-examination-board-feedback/${this.configuration.encodeParam({name: "proposalId", value: proposalId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, diff --git a/Client/src/app/core/modules/openapi/api/proposal-controller.serviceInterface.ts b/Client/src/app/core/modules/openapi/api/proposal-controller.serviceInterface.ts index 5a93ac91..1a93bf10 100644 --- a/Client/src/app/core/modules/openapi/api/proposal-controller.serviceInterface.ts +++ b/Client/src/app/core/modules/openapi/api/proposal-controller.serviceInterface.ts @@ -63,6 +63,6 @@ export interface ProposalControllerServiceInterface { * * @param proposalId */ - requestFullFeedback(proposalId: number, extraHttpRequestParams?: any): Observable; + requestExaminationBoardFeedback(proposalId: number, extraHttpRequestParams?: any): Observable; } diff --git a/Client/src/app/core/modules/openapi/model/create-examination-board-dto.ts b/Client/src/app/core/modules/openapi/model/create-examination-board-dto.ts new file mode 100644 index 00000000..b008bfe4 --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/create-examination-board-dto.ts @@ -0,0 +1,15 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface CreateExaminationBoardDTO { + name: string; +} + diff --git a/Client/src/app/core/modules/openapi/model/degree-program-dto.ts b/Client/src/app/core/modules/openapi/model/degree-program-dto.ts index e05d448f..972e097f 100644 --- a/Client/src/app/core/modules/openapi/model/degree-program-dto.ts +++ b/Client/src/app/core/modules/openapi/model/degree-program-dto.ts @@ -9,6 +9,7 @@ */ import { DegreeProgramSpecializationDTO } from './degree-program-specialization-dto'; import { ResponsibleUserDTO } from './responsible-user-dto'; +import { ExaminationBoardSummaryDTO } from './examination-board-summary-dto'; export interface DegreeProgramDTO { @@ -16,5 +17,6 @@ export interface DegreeProgramDTO { name: string; responsibleUser: ResponsibleUserDTO; degreeProgramSpecializations?: Array; + examinationBoard?: ExaminationBoardSummaryDTO; } diff --git a/Client/src/app/core/modules/openapi/model/examination-board-dto.ts b/Client/src/app/core/modules/openapi/model/examination-board-dto.ts new file mode 100644 index 00000000..c7583a8a --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/examination-board-dto.ts @@ -0,0 +1,18 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { ResponsibleUserDTO } from './responsible-user-dto'; + + +export interface ExaminationBoardDTO { + examinationBoardId?: number; + name?: string; + members?: Array; +} + diff --git a/Client/src/app/core/modules/openapi/model/examination-board-summary-dto.ts b/Client/src/app/core/modules/openapi/model/examination-board-summary-dto.ts new file mode 100644 index 00000000..27859737 --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/examination-board-summary-dto.ts @@ -0,0 +1,16 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface ExaminationBoardSummaryDTO { + examinationBoardId?: number; + name?: string; +} + diff --git a/Client/src/app/core/modules/openapi/model/feedback-compact-dto.ts b/Client/src/app/core/modules/openapi/model/feedback-compact-dto.ts index 5a5b8b5d..c1d2b3b1 100644 --- a/Client/src/app/core/modules/openapi/model/feedback-compact-dto.ts +++ b/Client/src/app/core/modules/openapi/model/feedback-compact-dto.ts @@ -13,6 +13,7 @@ export interface FeedbackCompactDTO { feedbackId?: number; requestedFromUserName?: string; requestedFromSpecializationName?: string; + examinationBoardName?: string; requiredRole?: FeedbackCompactDTO.RequiredRoleEnum; status?: FeedbackCompactDTO.StatusEnum; invalidated?: boolean; diff --git a/Client/src/app/core/modules/openapi/model/feedback.ts b/Client/src/app/core/modules/openapi/model/feedback.ts index 05cd5507..c6f72d73 100644 --- a/Client/src/app/core/modules/openapi/model/feedback.ts +++ b/Client/src/app/core/modules/openapi/model/feedback.ts @@ -13,6 +13,7 @@ import { User } from './user'; export interface Feedback { feedbackId?: number; feedbackFrom?: User; + assignedReviewer?: User; invalidated?: boolean; requiredRole?: Feedback.RequiredRoleEnum; status: Feedback.StatusEnum; diff --git a/Client/src/app/core/modules/openapi/model/models.ts b/Client/src/app/core/modules/openapi/model/models.ts index ea08504e..23bd5f0d 100644 --- a/Client/src/app/core/modules/openapi/model/models.ts +++ b/Client/src/app/core/modules/openapi/model/models.ts @@ -3,8 +3,11 @@ export * from './completion-service-request-dto'; export * from './completion-service-response-dto'; export * from './create-degree-program-dto'; export * from './create-degree-program-specialization-dto'; +export * from './create-examination-board-dto'; export * from './degree-program-dto'; export * from './degree-program-specialization-dto'; +export * from './examination-board-dto'; +export * from './examination-board-summary-dto'; export * from './feedback'; export * from './feedback-compact-dto'; export * from './feedback-dto'; @@ -23,6 +26,7 @@ export * from './responsible-user-dto'; export * from './similar-module-dto'; export * from './update-degree-program-dto'; export * from './update-degree-program-specialization-dto'; +export * from './update-examination-board-dto'; export * from './update-user-role-dto'; export * from './user'; export * from './user-dto'; diff --git a/Client/src/app/core/modules/openapi/model/module-version-compact-dto.ts b/Client/src/app/core/modules/openapi/model/module-version-compact-dto.ts index ab14aad0..ff375d24 100644 --- a/Client/src/app/core/modules/openapi/model/module-version-compact-dto.ts +++ b/Client/src/app/core/modules/openapi/model/module-version-compact-dto.ts @@ -19,16 +19,20 @@ export interface ModuleVersionCompactDTO { feedbackList?: Array; } export namespace ModuleVersionCompactDTO { - export type StatusEnum = 'PENDING_FIRST_SUBMISSION' | 'PENDING_COORDINATOR_FEEDBACK' | 'COORDINATOR_FEEDBACK_GIVEN' | 'PENDING_FULL_SUBMISSION' | 'PENDING_FULL_FEEDBACK' | 'ACCEPTED' | 'REQUIRES_REVIEW' | 'REJECTED' | 'CANCELLED'; + export type StatusEnum = 'WAITING_FOR_COORDINATORS_SUBMISSION' | 'PENDING_COORDINATORS_FEEDBACK' | 'COORDINATORS_FEEDBACK_GIVEN' | 'WAITING_FOR_EXAMINATION_BOARD_SUBMISSION' | 'PENDING_EXAMINATION_BOARD_FEEDBACK' | 'EXAMINATION_BOARD_FEEDBACK_GIVEN' | 'WAITING_FOR_QUALITY_MANAGEMENT_SUBMISSION' | 'PENDING_QUALITY_MANAGEMENT_FEEDBACK' | 'ACCEPTED' | 'REQUIRES_REVIEW' | 'REJECTED_AT_COORDINATORS_FEEDBACK' | 'REJECTED_AT_EXAMINATION_BOARD_FEEDBACK' | 'CANCELLED'; export const StatusEnum = { - PendingFirstSubmission: 'PENDING_FIRST_SUBMISSION' as StatusEnum, - PendingCoordinatorFeedback: 'PENDING_COORDINATOR_FEEDBACK' as StatusEnum, - CoordinatorFeedbackGiven: 'COORDINATOR_FEEDBACK_GIVEN' as StatusEnum, - PendingFullSubmission: 'PENDING_FULL_SUBMISSION' as StatusEnum, - PendingFullFeedback: 'PENDING_FULL_FEEDBACK' as StatusEnum, + WaitingForCoordinatorsSubmission: 'WAITING_FOR_COORDINATORS_SUBMISSION' as StatusEnum, + PendingCoordinatorsFeedback: 'PENDING_COORDINATORS_FEEDBACK' as StatusEnum, + CoordinatorsFeedbackGiven: 'COORDINATORS_FEEDBACK_GIVEN' as StatusEnum, + WaitingForExaminationBoardSubmission: 'WAITING_FOR_EXAMINATION_BOARD_SUBMISSION' as StatusEnum, + PendingExaminationBoardFeedback: 'PENDING_EXAMINATION_BOARD_FEEDBACK' as StatusEnum, + ExaminationBoardFeedbackGiven: 'EXAMINATION_BOARD_FEEDBACK_GIVEN' as StatusEnum, + WaitingForQualityManagementSubmission: 'WAITING_FOR_QUALITY_MANAGEMENT_SUBMISSION' as StatusEnum, + PendingQualityManagementFeedback: 'PENDING_QUALITY_MANAGEMENT_FEEDBACK' as StatusEnum, Accepted: 'ACCEPTED' as StatusEnum, RequiresReview: 'REQUIRES_REVIEW' as StatusEnum, - Rejected: 'REJECTED' as StatusEnum, + RejectedAtCoordinatorsFeedback: 'REJECTED_AT_COORDINATORS_FEEDBACK' as StatusEnum, + RejectedAtExaminationBoardFeedback: 'REJECTED_AT_EXAMINATION_BOARD_FEEDBACK' as StatusEnum, Cancelled: 'CANCELLED' as StatusEnum }; } diff --git a/Client/src/app/core/modules/openapi/model/module-version-update-request-dto.ts b/Client/src/app/core/modules/openapi/model/module-version-update-request-dto.ts index db5dff10..e73266e1 100644 --- a/Client/src/app/core/modules/openapi/model/module-version-update-request-dto.ts +++ b/Client/src/app/core/modules/openapi/model/module-version-update-request-dto.ts @@ -50,16 +50,20 @@ export interface ModuleVersionUpdateRequestDTO { lvSwsLecturerEng?: string; } export namespace ModuleVersionUpdateRequestDTO { - export type StatusEnum = 'PENDING_FIRST_SUBMISSION' | 'PENDING_COORDINATOR_FEEDBACK' | 'COORDINATOR_FEEDBACK_GIVEN' | 'PENDING_FULL_SUBMISSION' | 'PENDING_FULL_FEEDBACK' | 'ACCEPTED' | 'REQUIRES_REVIEW' | 'REJECTED' | 'CANCELLED'; + export type StatusEnum = 'WAITING_FOR_COORDINATORS_SUBMISSION' | 'PENDING_COORDINATORS_FEEDBACK' | 'COORDINATORS_FEEDBACK_GIVEN' | 'WAITING_FOR_EXAMINATION_BOARD_SUBMISSION' | 'PENDING_EXAMINATION_BOARD_FEEDBACK' | 'EXAMINATION_BOARD_FEEDBACK_GIVEN' | 'WAITING_FOR_QUALITY_MANAGEMENT_SUBMISSION' | 'PENDING_QUALITY_MANAGEMENT_FEEDBACK' | 'ACCEPTED' | 'REQUIRES_REVIEW' | 'REJECTED_AT_COORDINATORS_FEEDBACK' | 'REJECTED_AT_EXAMINATION_BOARD_FEEDBACK' | 'CANCELLED'; export const StatusEnum = { - PendingFirstSubmission: 'PENDING_FIRST_SUBMISSION' as StatusEnum, - PendingCoordinatorFeedback: 'PENDING_COORDINATOR_FEEDBACK' as StatusEnum, - CoordinatorFeedbackGiven: 'COORDINATOR_FEEDBACK_GIVEN' as StatusEnum, - PendingFullSubmission: 'PENDING_FULL_SUBMISSION' as StatusEnum, - PendingFullFeedback: 'PENDING_FULL_FEEDBACK' as StatusEnum, + WaitingForCoordinatorsSubmission: 'WAITING_FOR_COORDINATORS_SUBMISSION' as StatusEnum, + PendingCoordinatorsFeedback: 'PENDING_COORDINATORS_FEEDBACK' as StatusEnum, + CoordinatorsFeedbackGiven: 'COORDINATORS_FEEDBACK_GIVEN' as StatusEnum, + WaitingForExaminationBoardSubmission: 'WAITING_FOR_EXAMINATION_BOARD_SUBMISSION' as StatusEnum, + PendingExaminationBoardFeedback: 'PENDING_EXAMINATION_BOARD_FEEDBACK' as StatusEnum, + ExaminationBoardFeedbackGiven: 'EXAMINATION_BOARD_FEEDBACK_GIVEN' as StatusEnum, + WaitingForQualityManagementSubmission: 'WAITING_FOR_QUALITY_MANAGEMENT_SUBMISSION' as StatusEnum, + PendingQualityManagementFeedback: 'PENDING_QUALITY_MANAGEMENT_FEEDBACK' as StatusEnum, Accepted: 'ACCEPTED' as StatusEnum, RequiresReview: 'REQUIRES_REVIEW' as StatusEnum, - Rejected: 'REJECTED' as StatusEnum, + RejectedAtCoordinatorsFeedback: 'REJECTED_AT_COORDINATORS_FEEDBACK' as StatusEnum, + RejectedAtExaminationBoardFeedback: 'REJECTED_AT_EXAMINATION_BOARD_FEEDBACK' as StatusEnum, Cancelled: 'CANCELLED' as StatusEnum }; export type LanguageEngEnum = 'English' | 'German'; diff --git a/Client/src/app/core/modules/openapi/model/module-version-view-dto.ts b/Client/src/app/core/modules/openapi/model/module-version-view-dto.ts index f194668c..70bcb929 100644 --- a/Client/src/app/core/modules/openapi/model/module-version-view-dto.ts +++ b/Client/src/app/core/modules/openapi/model/module-version-view-dto.ts @@ -53,16 +53,20 @@ export interface ModuleVersionViewDTO { feedbacks?: Array; } export namespace ModuleVersionViewDTO { - export type StatusEnum = 'PENDING_FIRST_SUBMISSION' | 'PENDING_COORDINATOR_FEEDBACK' | 'COORDINATOR_FEEDBACK_GIVEN' | 'PENDING_FULL_SUBMISSION' | 'PENDING_FULL_FEEDBACK' | 'ACCEPTED' | 'REQUIRES_REVIEW' | 'REJECTED' | 'CANCELLED'; + export type StatusEnum = 'WAITING_FOR_COORDINATORS_SUBMISSION' | 'PENDING_COORDINATORS_FEEDBACK' | 'COORDINATORS_FEEDBACK_GIVEN' | 'WAITING_FOR_EXAMINATION_BOARD_SUBMISSION' | 'PENDING_EXAMINATION_BOARD_FEEDBACK' | 'EXAMINATION_BOARD_FEEDBACK_GIVEN' | 'WAITING_FOR_QUALITY_MANAGEMENT_SUBMISSION' | 'PENDING_QUALITY_MANAGEMENT_FEEDBACK' | 'ACCEPTED' | 'REQUIRES_REVIEW' | 'REJECTED_AT_COORDINATORS_FEEDBACK' | 'REJECTED_AT_EXAMINATION_BOARD_FEEDBACK' | 'CANCELLED'; export const StatusEnum = { - PendingFirstSubmission: 'PENDING_FIRST_SUBMISSION' as StatusEnum, - PendingCoordinatorFeedback: 'PENDING_COORDINATOR_FEEDBACK' as StatusEnum, - CoordinatorFeedbackGiven: 'COORDINATOR_FEEDBACK_GIVEN' as StatusEnum, - PendingFullSubmission: 'PENDING_FULL_SUBMISSION' as StatusEnum, - PendingFullFeedback: 'PENDING_FULL_FEEDBACK' as StatusEnum, + WaitingForCoordinatorsSubmission: 'WAITING_FOR_COORDINATORS_SUBMISSION' as StatusEnum, + PendingCoordinatorsFeedback: 'PENDING_COORDINATORS_FEEDBACK' as StatusEnum, + CoordinatorsFeedbackGiven: 'COORDINATORS_FEEDBACK_GIVEN' as StatusEnum, + WaitingForExaminationBoardSubmission: 'WAITING_FOR_EXAMINATION_BOARD_SUBMISSION' as StatusEnum, + PendingExaminationBoardFeedback: 'PENDING_EXAMINATION_BOARD_FEEDBACK' as StatusEnum, + ExaminationBoardFeedbackGiven: 'EXAMINATION_BOARD_FEEDBACK_GIVEN' as StatusEnum, + WaitingForQualityManagementSubmission: 'WAITING_FOR_QUALITY_MANAGEMENT_SUBMISSION' as StatusEnum, + PendingQualityManagementFeedback: 'PENDING_QUALITY_MANAGEMENT_FEEDBACK' as StatusEnum, Accepted: 'ACCEPTED' as StatusEnum, RequiresReview: 'REQUIRES_REVIEW' as StatusEnum, - Rejected: 'REJECTED' as StatusEnum, + RejectedAtCoordinatorsFeedback: 'REJECTED_AT_COORDINATORS_FEEDBACK' as StatusEnum, + RejectedAtExaminationBoardFeedback: 'REJECTED_AT_EXAMINATION_BOARD_FEEDBACK' as StatusEnum, Cancelled: 'CANCELLED' as StatusEnum }; export type LanguageEngEnum = 'English' | 'German'; diff --git a/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts b/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts index 57798be9..9db0bde0 100644 --- a/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts +++ b/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts @@ -14,9 +14,11 @@ export interface ModuleVersionViewFeedbackDTO { feedbackFromFirstName?: string; feedbackFromLastName?: string; rejectionComment?: string; - feedbackRole?: ModuleVersionViewFeedbackDTO.FeedbackRoleEnum; + requiredRole?: ModuleVersionViewFeedbackDTO.RequiredRoleEnum; requestedFromUserName?: string; requestedFromSpecializationName?: string; + examinationBoardId?: number; + examinationBoardName?: string; feedbackStatus?: ModuleVersionViewFeedbackDTO.FeedbackStatusEnum; createdAt?: string; submissionDate?: string; @@ -50,15 +52,15 @@ export interface ModuleVersionViewFeedbackDTO { lvSwsLecturerFeedback?: string; } export namespace ModuleVersionViewFeedbackDTO { - export type FeedbackRoleEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR'; - export const FeedbackRoleEnum = { - Admin: 'ADMIN' as FeedbackRoleEnum, - QualityManagement: 'QUALITY_MANAGEMENT' as FeedbackRoleEnum, - AcademicProgramAdvisor: 'ACADEMIC_PROGRAM_ADVISOR' as FeedbackRoleEnum, - ExaminationBoard: 'EXAMINATION_BOARD' as FeedbackRoleEnum, - Professor: 'PROFESSOR' as FeedbackRoleEnum, - ProgramCoordinator: 'PROGRAM_COORDINATOR' as FeedbackRoleEnum, - SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as FeedbackRoleEnum + export type RequiredRoleEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR'; + export const RequiredRoleEnum = { + Admin: 'ADMIN' as RequiredRoleEnum, + QualityManagement: 'QUALITY_MANAGEMENT' as RequiredRoleEnum, + AcademicProgramAdvisor: 'ACADEMIC_PROGRAM_ADVISOR' as RequiredRoleEnum, + ExaminationBoard: 'EXAMINATION_BOARD' as RequiredRoleEnum, + Professor: 'PROFESSOR' as RequiredRoleEnum, + ProgramCoordinator: 'PROGRAM_COORDINATOR' as RequiredRoleEnum, + SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as RequiredRoleEnum }; export type FeedbackStatusEnum = 'PENDING_FEEDBACK' | 'APPROVED' | 'FEEDBACK_GIVEN' | 'REJECTED'; export const FeedbackStatusEnum = { diff --git a/Client/src/app/core/modules/openapi/model/proposal-view-dto.ts b/Client/src/app/core/modules/openapi/model/proposal-view-dto.ts index f1eda8fd..8dfc2bf0 100644 --- a/Client/src/app/core/modules/openapi/model/proposal-view-dto.ts +++ b/Client/src/app/core/modules/openapi/model/proposal-view-dto.ts @@ -19,16 +19,20 @@ export interface ProposalViewDTO { oldModuleVersions?: Array; } export namespace ProposalViewDTO { - export type StatusEnum = 'PENDING_FIRST_SUBMISSION' | 'PENDING_COORDINATOR_FEEDBACK' | 'COORDINATOR_FEEDBACK_GIVEN' | 'PENDING_FULL_SUBMISSION' | 'PENDING_FULL_FEEDBACK' | 'ACCEPTED' | 'REQUIRES_REVIEW' | 'REJECTED' | 'CANCELLED'; + export type StatusEnum = 'WAITING_FOR_COORDINATORS_SUBMISSION' | 'PENDING_COORDINATORS_FEEDBACK' | 'COORDINATORS_FEEDBACK_GIVEN' | 'WAITING_FOR_EXAMINATION_BOARD_SUBMISSION' | 'PENDING_EXAMINATION_BOARD_FEEDBACK' | 'EXAMINATION_BOARD_FEEDBACK_GIVEN' | 'WAITING_FOR_QUALITY_MANAGEMENT_SUBMISSION' | 'PENDING_QUALITY_MANAGEMENT_FEEDBACK' | 'ACCEPTED' | 'REQUIRES_REVIEW' | 'REJECTED_AT_COORDINATORS_FEEDBACK' | 'REJECTED_AT_EXAMINATION_BOARD_FEEDBACK' | 'CANCELLED'; export const StatusEnum = { - PendingFirstSubmission: 'PENDING_FIRST_SUBMISSION' as StatusEnum, - PendingCoordinatorFeedback: 'PENDING_COORDINATOR_FEEDBACK' as StatusEnum, - CoordinatorFeedbackGiven: 'COORDINATOR_FEEDBACK_GIVEN' as StatusEnum, - PendingFullSubmission: 'PENDING_FULL_SUBMISSION' as StatusEnum, - PendingFullFeedback: 'PENDING_FULL_FEEDBACK' as StatusEnum, + WaitingForCoordinatorsSubmission: 'WAITING_FOR_COORDINATORS_SUBMISSION' as StatusEnum, + PendingCoordinatorsFeedback: 'PENDING_COORDINATORS_FEEDBACK' as StatusEnum, + CoordinatorsFeedbackGiven: 'COORDINATORS_FEEDBACK_GIVEN' as StatusEnum, + WaitingForExaminationBoardSubmission: 'WAITING_FOR_EXAMINATION_BOARD_SUBMISSION' as StatusEnum, + PendingExaminationBoardFeedback: 'PENDING_EXAMINATION_BOARD_FEEDBACK' as StatusEnum, + ExaminationBoardFeedbackGiven: 'EXAMINATION_BOARD_FEEDBACK_GIVEN' as StatusEnum, + WaitingForQualityManagementSubmission: 'WAITING_FOR_QUALITY_MANAGEMENT_SUBMISSION' as StatusEnum, + PendingQualityManagementFeedback: 'PENDING_QUALITY_MANAGEMENT_FEEDBACK' as StatusEnum, Accepted: 'ACCEPTED' as StatusEnum, RequiresReview: 'REQUIRES_REVIEW' as StatusEnum, - Rejected: 'REJECTED' as StatusEnum, + RejectedAtCoordinatorsFeedback: 'REJECTED_AT_COORDINATORS_FEEDBACK' as StatusEnum, + RejectedAtExaminationBoardFeedback: 'REJECTED_AT_EXAMINATION_BOARD_FEEDBACK' as StatusEnum, Cancelled: 'CANCELLED' as StatusEnum }; } diff --git a/Client/src/app/core/modules/openapi/model/proposals-compact-dto.ts b/Client/src/app/core/modules/openapi/model/proposals-compact-dto.ts index b5b63747..2e018f20 100644 --- a/Client/src/app/core/modules/openapi/model/proposals-compact-dto.ts +++ b/Client/src/app/core/modules/openapi/model/proposals-compact-dto.ts @@ -17,16 +17,20 @@ export interface ProposalsCompactDTO { latestTitle?: string; } export namespace ProposalsCompactDTO { - export type StatusEnum = 'PENDING_FIRST_SUBMISSION' | 'PENDING_COORDINATOR_FEEDBACK' | 'COORDINATOR_FEEDBACK_GIVEN' | 'PENDING_FULL_SUBMISSION' | 'PENDING_FULL_FEEDBACK' | 'ACCEPTED' | 'REQUIRES_REVIEW' | 'REJECTED' | 'CANCELLED'; + export type StatusEnum = 'WAITING_FOR_COORDINATORS_SUBMISSION' | 'PENDING_COORDINATORS_FEEDBACK' | 'COORDINATORS_FEEDBACK_GIVEN' | 'WAITING_FOR_EXAMINATION_BOARD_SUBMISSION' | 'PENDING_EXAMINATION_BOARD_FEEDBACK' | 'EXAMINATION_BOARD_FEEDBACK_GIVEN' | 'WAITING_FOR_QUALITY_MANAGEMENT_SUBMISSION' | 'PENDING_QUALITY_MANAGEMENT_FEEDBACK' | 'ACCEPTED' | 'REQUIRES_REVIEW' | 'REJECTED_AT_COORDINATORS_FEEDBACK' | 'REJECTED_AT_EXAMINATION_BOARD_FEEDBACK' | 'CANCELLED'; export const StatusEnum = { - PendingFirstSubmission: 'PENDING_FIRST_SUBMISSION' as StatusEnum, - PendingCoordinatorFeedback: 'PENDING_COORDINATOR_FEEDBACK' as StatusEnum, - CoordinatorFeedbackGiven: 'COORDINATOR_FEEDBACK_GIVEN' as StatusEnum, - PendingFullSubmission: 'PENDING_FULL_SUBMISSION' as StatusEnum, - PendingFullFeedback: 'PENDING_FULL_FEEDBACK' as StatusEnum, + WaitingForCoordinatorsSubmission: 'WAITING_FOR_COORDINATORS_SUBMISSION' as StatusEnum, + PendingCoordinatorsFeedback: 'PENDING_COORDINATORS_FEEDBACK' as StatusEnum, + CoordinatorsFeedbackGiven: 'COORDINATORS_FEEDBACK_GIVEN' as StatusEnum, + WaitingForExaminationBoardSubmission: 'WAITING_FOR_EXAMINATION_BOARD_SUBMISSION' as StatusEnum, + PendingExaminationBoardFeedback: 'PENDING_EXAMINATION_BOARD_FEEDBACK' as StatusEnum, + ExaminationBoardFeedbackGiven: 'EXAMINATION_BOARD_FEEDBACK_GIVEN' as StatusEnum, + WaitingForQualityManagementSubmission: 'WAITING_FOR_QUALITY_MANAGEMENT_SUBMISSION' as StatusEnum, + PendingQualityManagementFeedback: 'PENDING_QUALITY_MANAGEMENT_FEEDBACK' as StatusEnum, Accepted: 'ACCEPTED' as StatusEnum, RequiresReview: 'REQUIRES_REVIEW' as StatusEnum, - Rejected: 'REJECTED' as StatusEnum, + RejectedAtCoordinatorsFeedback: 'REJECTED_AT_COORDINATORS_FEEDBACK' as StatusEnum, + RejectedAtExaminationBoardFeedback: 'REJECTED_AT_EXAMINATION_BOARD_FEEDBACK' as StatusEnum, Cancelled: 'CANCELLED' as StatusEnum }; } diff --git a/Client/src/app/core/modules/openapi/model/update-degree-program-dto.ts b/Client/src/app/core/modules/openapi/model/update-degree-program-dto.ts index 34094e90..a95b3f1b 100644 --- a/Client/src/app/core/modules/openapi/model/update-degree-program-dto.ts +++ b/Client/src/app/core/modules/openapi/model/update-degree-program-dto.ts @@ -12,5 +12,6 @@ export interface UpdateDegreeProgramDTO { name?: string; responsibleUserId?: string; + examinationBoardId?: number; } diff --git a/Client/src/app/core/modules/openapi/model/update-examination-board-dto.ts b/Client/src/app/core/modules/openapi/model/update-examination-board-dto.ts new file mode 100644 index 00000000..380b4572 --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/update-examination-board-dto.ts @@ -0,0 +1,16 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface UpdateExaminationBoardDTO { + name: string; + userIds: Array; +} + diff --git a/Client/src/app/core/security/keycloak.service.ts b/Client/src/app/core/security/keycloak.service.ts index f17f6bd7..c865bb6f 100644 --- a/Client/src/app/core/security/keycloak.service.ts +++ b/Client/src/app/core/security/keycloak.service.ts @@ -1,7 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { environment } from '../../../../environments/environment'; -import Keycloak from 'keycloak-js'; +import Keycloak, { type KeycloakTokenParsed } from 'keycloak-js'; import { KeycloakCredentialType } from './keycloak-credentials.types'; @Injectable({ providedIn: 'root' }) @@ -54,16 +54,48 @@ export class KeycloakService { } } - login(returnUrl?: string) { - return this.keycloak.login({ redirectUri: window.location.origin + (returnUrl || ''), action: 'webauthn-register-passwordless:skip_if_exists' }); + loginWithTumRedirect(returnUrl?: string) { + return this.keycloak.login({ redirectUri: window.location.origin + (returnUrl ?? '') }); } - logout() { - return this.keycloak.logout({ redirectUri: environment.redirect }); + applyPasskeyTokens(accessToken: string, refreshToken: string): void { + const kc = this.keycloak; + kc.token = accessToken; + kc.refreshToken = refreshToken; + kc.idToken = undefined; + kc.idTokenParsed = undefined; + + const parsed = this.parseJwtPayload(accessToken); + kc.tokenParsed = parsed; + kc.refreshTokenParsed = this.parseJwtPayload(refreshToken); + + if (parsed) { + kc.subject = parsed.sub; + const ext = parsed as KeycloakTokenParsed & { sid?: string; session_state?: string }; + kc.sessionId = ext.sid ?? ext.session_state; + kc.realmAccess = parsed.realm_access; + kc.resourceAccess = parsed.resource_access; + } + kc.timeSkew = 0; + kc.authenticated = true; } - registerPasskey(returnUrl?: string) { - return this.keycloak.login({ redirectUri: window.location.origin + (returnUrl || ''), action: 'webauthn-register-passwordless' }); + private parseJwtPayload(token: string): KeycloakTokenParsed | undefined { + try { + const parts = token.split('.'); + if (parts.length < 2) { + return undefined; + } + const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + return JSON.parse(atob(padded)) as KeycloakTokenParsed; + } catch { + return undefined; + } + } + + logout() { + return this.keycloak.logout({ redirectUri: environment.redirect }); } getCredentials() { diff --git a/Client/src/app/core/security/passkey-extension.service.ts b/Client/src/app/core/security/passkey-extension.service.ts new file mode 100644 index 00000000..c123d59d --- /dev/null +++ b/Client/src/app/core/security/passkey-extension.service.ts @@ -0,0 +1,170 @@ +import { inject, Injectable } from '@angular/core'; +import { environment } from '../../../../environments/environment'; +import { KeycloakService } from './keycloak.service'; + +/** + * In-app WebAuthn via Keycloak realm extension (same flow as ba-test-keycloak {@code public/app.js}): + * register: {@code /passkey/challenge} + {@code /passkey/save}; sign-in: {@code /passkey/get-credential-id} + {@code /passkey/authenticate}. + */ +@Injectable({ providedIn: 'root' }) +export class PasskeyExtensionService { + private readonly keycloakService = inject(KeycloakService); + + private passkeyBaseUrl(): string { + const base = environment.keycloak.url.replace(/\/$/, ''); + const realm = encodeURIComponent(environment.keycloak.realm); + return `${base}/realms/${realm}/passkey`; + } + + private getUrl(path: string): string { + const p = path.replace(/^\/+/, ''); + return `${this.passkeyBaseUrl()}/${p}`; + } + + private base64UrlToUint8Array(value: string): Uint8Array { + const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)); + } + + private bufferToBase64Url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + } + + /** + * Register a new passkey for the current user (must already be logged in). + */ + async registerPasskeyInBrowser(): Promise { + const kc = this.keycloakService.keycloak; + if (!kc.authenticated || !kc.token) { + throw new Error('You must be signed in to register a passkey.'); + } + + await kc.updateToken(60); + const token = kc.token; + if (!token) { + throw new Error('No access token available.'); + } + + const parsed = kc.tokenParsed as Record | undefined; + const accountId = String(parsed?.['sub'] ?? parsed?.['preferred_username'] ?? ''); + const accountName = String(parsed?.['preferred_username'] ?? parsed?.['email'] ?? ''); + const displayName = String( + parsed?.['name'] ?? + ([parsed?.['given_name'], parsed?.['family_name']].filter(Boolean).join(' ') || accountName || 'User') + ); + + if (!accountId || !accountName) { + throw new Error('Missing user identity in token for passkey registration.'); + } + + const challengeRes = await fetch(this.getUrl('challenge')); + if (!challengeRes.ok) { + throw new Error(`Failed to get WebAuthn challenge (${challengeRes.status})`); + } + const { challenge } = (await challengeRes.json()) as { challenge: string }; + if (!challenge) { + throw new Error('Invalid challenge response from Keycloak'); + } + + const userIdBytes = new TextEncoder().encode(accountId).slice(0, 64); + + const credential = (await navigator.credentials.create({ + publicKey: { + challenge: this.base64UrlToUint8Array(challenge) as BufferSource, + rp: { name: 'Module Management', id: window.location.hostname }, + user: { id: userIdBytes, name: accountName, displayName }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + authenticatorSelection: { userVerification: 'preferred', residentKey: 'required' }, + attestation: 'none' + } + })) as PublicKeyCredential | null; + + if (!credential?.response) { + throw new Error('Passkey creation was cancelled or failed.'); + } + + const response = credential.response as AuthenticatorAttestationResponse; + const savePayload = { + credentialId: this.bufferToBase64Url(credential.rawId), + rawId: this.bufferToBase64Url(credential.rawId), + clientDataJSON: this.bufferToBase64Url(response.clientDataJSON), + attestationObject: this.bufferToBase64Url(response.attestationObject) + }; + + const saveRes = await fetch(this.getUrl('save'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(savePayload) + }); + + const saveText = await saveRes.text(); + if (!saveRes.ok) { + throw new Error(saveText || `Failed to store passkey (${saveRes.status})`); + } + + await kc.updateToken(-1); + } + + /** + * Sign in with passkey only (no Keycloak UI redirect). Returns tokens from {@code POST /passkey/authenticate}. + */ + async signInWithPasskey(): Promise<{ access_token: string; refresh_token: string }> { + const optionsResponse = await fetch(this.getUrl('get-credential-id')); + const res = (await optionsResponse.json()) as { challenge?: string; credentialId?: string; error?: string }; + if (!optionsResponse.ok) { + throw new Error(res?.error || `Failed to get passkey options (${optionsResponse.status})`); + } + if (!res.challenge) { + throw new Error('Invalid challenge response from server'); + } + + const publicKey: PublicKeyCredentialRequestOptions = { + challenge: this.base64UrlToUint8Array(res.challenge) as BufferSource, + userVerification: 'preferred' + }; + if (res.credentialId) { + publicKey.allowCredentials = [ + { type: 'public-key', id: this.base64UrlToUint8Array(res.credentialId) as BufferSource } + ]; + } + + const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential | null; + if (!credential?.response) { + throw new Error('Passkey sign-in was cancelled or failed.'); + } + + const ar = credential.response as AuthenticatorAssertionResponse; + const payload = { + credentialId: this.bufferToBase64Url(credential.rawId), + rawId: this.bufferToBase64Url(credential.rawId), + clientDataJSON: this.bufferToBase64Url(ar.clientDataJSON), + authenticatorData: this.bufferToBase64Url(ar.authenticatorData), + signature: this.bufferToBase64Url(ar.signature), + challenge: res.challenge + }; + + const authRes = await fetch(this.getUrl('authenticate'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const authResult = (await authRes.json()) as { access_token?: string; refresh_token?: string; error?: string }; + if (!authRes.ok) { + throw new Error(authResult?.error || `Passkey authentication failed (${authRes.status})`); + } + if (!authResult.access_token || !authResult.refresh_token) { + throw new Error('Invalid token response from server'); + } + return { access_token: authResult.access_token, refresh_token: authResult.refresh_token }; + } +} diff --git a/Client/src/app/core/security/security-store.service.ts b/Client/src/app/core/security/security-store.service.ts index 90909ba4..9338ef22 100644 --- a/Client/src/app/core/security/security-store.service.ts +++ b/Client/src/app/core/security/security-store.service.ts @@ -1,18 +1,27 @@ import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core'; import { isPlatformServer } from '@angular/common'; import { KeycloakService } from './keycloak.service'; +import { PasskeyExtensionService } from './passkey-extension.service'; import { firstValueFrom } from 'rxjs'; import { UserControllerService, User } from '../modules/openapi'; import { Passkey } from './keycloak-credentials.types'; +import { MessageService } from 'primeng/api'; + +function passkeyDialogDismissedStorageKey(sub: string): string { + return `mm_passkey_dialog_dismissed_${sub}`; +} @Injectable({ providedIn: 'root' }) export class SecurityStore { keycloakService = inject(KeycloakService); + passkeyExtension = inject(PasskeyExtensionService); userControllerService = inject(UserControllerService); + private readonly messageService = inject(MessageService); isLoading = signal(false); user = signal(undefined); passkeys = signal([]); + passkeyDialogVisible = signal(false); constructor() { this.onInit(); @@ -29,36 +38,87 @@ export class SecurityStore { const isLoggedIn = await this.keycloakService.init(); if (isLoggedIn) { - this.loadPasskeys(); + await this.loadPasskeys(); try { const user = await firstValueFrom(this.userControllerService.getCurrentUser()); this.user.set(user); } catch (error) { + this.messageService.add({ severity: 'error', summary: 'Sign-in', detail: 'Something went wrong' }); console.error('error fetching user details', error); this.user.set(undefined); } + this.evaluatePasskeyDialogAfterTumLogin(); } this.isLoading.set(false); } + closePasskeyDialog(dontShowAgain: boolean): void { + if (!this.passkeyDialogVisible()) { + return; + } + if (dontShowAgain) { + const sub = this.keycloakService.keycloak.tokenParsed?.sub; + if (sub) { + localStorage.setItem(passkeyDialogDismissedStorageKey(sub), '1'); + } + } + this.passkeyDialogVisible.set(false); + } + + private evaluatePasskeyDialogAfterTumLogin(): void { + if (this.passkeys().length > 0) { + return; + } + const sub = this.keycloakService.keycloak.tokenParsed?.sub; + if (!sub) { + return; + } + if (localStorage.getItem(passkeyDialogDismissedStorageKey(sub)) === '1') { + return; + } + this.passkeyDialogVisible.set(true); + } + + async signInWithTum(returnUrl?: string) { + await this.keycloakService.loginWithTumRedirect(returnUrl); + } + async signIn(returnUrl?: string) { - await this.keycloakService.login(returnUrl); + await this.signInWithTum(returnUrl); + } + + async signInWithPasskey(): Promise { + this.isLoading.set(true); + try { + const tokens = await this.passkeyExtension.signInWithPasskey(); + this.keycloakService.applyPasskeyTokens(tokens.access_token, tokens.refresh_token); + await this.loadPasskeys(); + const user = await firstValueFrom(this.userControllerService.getCurrentUser()); + this.user.set(user); + } catch (error) { + this.messageService.add({ severity: 'error', summary: 'Sign-in', detail: 'Something went wrong' }); + console.error(error); + } finally { + this.isLoading.set(false); + } } async signOut() { await this.keycloakService.logout(); this.user.set(undefined); this.passkeys.set([]); + this.passkeyDialogVisible.set(false); } - async registerPasskey(returnUrl?: string) { - return await this.keycloakService.registerPasskey(returnUrl); + async registerPasskey(_returnUrl?: string) { + await this.passkeyExtension.registerPasskeyInBrowser(); + await this.loadPasskeys(); } async deletePasskey(credentialId: string) { try { await firstValueFrom(this.keycloakService.deleteCredential(credentialId)); - this.loadPasskeys(); + await this.loadPasskeys(); } catch (error) { console.error('Error deleting passkey:', error); } diff --git a/Client/src/app/layout.component.html b/Client/src/app/layout.component.html index 0e9c8948..956e9709 100644 --- a/Client/src/app/layout.component.html +++ b/Client/src/app/layout.component.html @@ -9,4 +9,37 @@ + + +
+

+ You can add a + passkey + to sign in next time without leaving this app—using your device PIN, fingerprint, or security key. +

+
    +
  • Quicker sign-in for day-to-day work
  • +
  • Phishing-resistant authentication
  • +
  • Your passkey stays on your device; you can remove it anytime in Account → Passkeys
  • +
+
+ + +
+
+ + + + +
diff --git a/Client/src/app/layout.component.ts b/Client/src/app/layout.component.ts index 451ea8f6..3481161f 100644 --- a/Client/src/app/layout.component.ts +++ b/Client/src/app/layout.component.ts @@ -1,19 +1,44 @@ import { Component, inject } from '@angular/core'; -import { RouterModule } from '@angular/router'; +import { Router, RouterModule } from '@angular/router'; +import { FormsModule } from '@angular/forms'; import { HeaderComponent } from './components/header/header.component'; import { SideBarComponent } from './components/side-bar/side-bar.component'; import { BreadcrumbComponent } from './components/breadcrumb/breadcrumb.component'; import { SidebarService } from './components/side-bar/sidebar.service'; import { SecurityStore } from './core/security/security-store.service'; +import { DialogModule } from 'primeng/dialog'; +import { ButtonModule } from 'primeng/button'; +import { CheckboxModule } from 'primeng/checkbox'; @Component({ selector: 'app-layout', standalone: true, - imports: [RouterModule, HeaderComponent, SideBarComponent, BreadcrumbComponent], + imports: [RouterModule, FormsModule, HeaderComponent, SideBarComponent, BreadcrumbComponent, DialogModule, ButtonModule, CheckboxModule], templateUrl: './layout.component.html' }) export class LayoutComponent { sidebarService = inject(SidebarService); securityStore = inject(SecurityStore); + private readonly router = inject(Router); user = this.securityStore.user; + + dontShowPasskeyDialogAgain = false; + + onPasskeyDialogVisibleChange(visible: boolean): void { + if (visible) { + this.dontShowPasskeyDialogAgain = false; + return; + } + this.securityStore.closePasskeyDialog(this.dontShowPasskeyDialogAgain); + } + + goToPasskeyRegistration(): void { + const skip = this.dontShowPasskeyDialogAgain; + this.securityStore.closePasskeyDialog(skip); + void this.router.navigate(['/account/passkeys']); + } + + dismissPasskeyDialog(): void { + this.securityStore.closePasskeyDialog(this.dontShowPasskeyDialogAgain); + } } diff --git a/Client/src/app/pages/account-management/passkeys/account-passkeys.component.ts b/Client/src/app/pages/account-management/passkeys/account-passkeys.component.ts index 9a8e4cfa..80fc66b4 100644 --- a/Client/src/app/pages/account-management/passkeys/account-passkeys.component.ts +++ b/Client/src/app/pages/account-management/passkeys/account-passkeys.component.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { SecurityStore } from '../../../core/security/security-store.service'; import { PanelModule } from 'primeng/panel'; import { ButtonModule } from 'primeng/button'; +import { MessageService } from 'primeng/api'; @Component({ selector: 'account-passkeys', @@ -12,11 +13,21 @@ import { ButtonModule } from 'primeng/button'; }) export class AccountPasskeysComponent { securityStore = inject(SecurityStore); + private readonly messageService = inject(MessageService); passkeys = this.securityStore.passkeys; async addPasskey() { - await this.securityStore.registerPasskey(window.location.pathname); + try { + await this.securityStore.registerPasskey(window.location.pathname); + } catch (e: unknown) { + const detail = e instanceof Error ? e.message : 'Passkey registration failed'; + this.messageService.add({ + severity: 'error', + summary: 'Passkey registration', + detail + }); + } } async deletePasskey(passkeyId: string) { diff --git a/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.html b/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.html index dd096001..604f5b4f 100644 --- a/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.html +++ b/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.html @@ -19,6 +19,20 @@

Program details

styleClass="w-full" /> +
+ + +
diff --git a/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.ts b/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.ts index afe422a9..216d520d 100644 --- a/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.ts +++ b/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.ts @@ -6,6 +6,7 @@ import { ButtonModule } from 'primeng/button'; import { InputTextModule } from 'primeng/inputtext'; import { DialogModule } from 'primeng/dialog'; import { MultiSelectModule } from 'primeng/multiselect'; +import { SelectModule } from 'primeng/select'; import { TooltipModule } from 'primeng/tooltip'; import { MessageService } from 'primeng/api'; import { ToastModule } from 'primeng/toast'; @@ -13,9 +14,11 @@ import { firstValueFrom } from 'rxjs'; import { DegreeProgramSpecializationsControllerService, DegreeProgramsControllerService, + ExaminationBoardControllerService, type CreateDegreeProgramSpecializationDTO, type DegreeProgramDTO, - type DegreeProgramSpecializationDTO + type DegreeProgramSpecializationDTO, + type ExaminationBoardSummaryDTO } from '../../../core/modules/openapi'; import { UsersSelectComponent } from '../../../components/users-select/users-select.component'; import { BreadcrumbLabelsService } from '../../../components/breadcrumb/breadcrumb-labels.service'; @@ -23,13 +26,14 @@ import { BreadcrumbLabelsService } from '../../../components/breadcrumb/breadcru @Component({ selector: 'app-degree-program-details-page', standalone: true, - imports: [RouterLink, FormsModule, TableModule, ButtonModule, InputTextModule, DialogModule, MultiSelectModule, TooltipModule, ToastModule, UsersSelectComponent], + imports: [RouterLink, FormsModule, TableModule, ButtonModule, InputTextModule, DialogModule, MultiSelectModule, SelectModule, TooltipModule, ToastModule, UsersSelectComponent], templateUrl: './degree-program-details-page.component.html' }) export class DegreeProgramDetailsPageComponent { private readonly route = inject(ActivatedRoute); private readonly degreeProgramsService = inject(DegreeProgramsControllerService); private readonly specializationsService = inject(DegreeProgramSpecializationsControllerService); + private readonly examinationBoardsService = inject(ExaminationBoardControllerService); private readonly messageService = inject(MessageService); private readonly breadcrumbLabels = inject(BreadcrumbLabelsService); @@ -43,6 +47,13 @@ export class DegreeProgramDetailsPageComponent { programName = signal(''); programResponsibleUserId = signal(null); + allExaminationBoards = signal([]); + programExaminationBoardId = signal(null); + examinationBoardOptions = computed(() => { + const boards = this.allExaminationBoards(); + return [{ label: '— No examination board —', value: null as number | null }, ...boards.map((b) => ({ label: b.name ?? '', value: b.examinationBoardId }))]; + }); + newSpecName = signal(''); newSpecResponsibleUserId = signal(null); @@ -65,10 +76,15 @@ export class DegreeProgramDetailsPageComponent { async loadProgram(id: number) { this.loading.set(true); try { - const program = await firstValueFrom(this.degreeProgramsService.getDegreeProgram(id)); + const [program, boards] = await Promise.all([ + firstValueFrom(this.degreeProgramsService.getDegreeProgram(id)), + firstValueFrom(this.examinationBoardsService.getAllExaminationBoards()) + ]); this.program.set(program); + this.allExaminationBoards.set(boards ?? []); this.programName.set(program.name ?? ''); this.programResponsibleUserId.set(program.responsibleUser.userId ?? null); + this.programExaminationBoardId.set(program.examinationBoard?.examinationBoardId ?? null); this.breadcrumbLabels.degreeProgramName.set(program.name ?? null); await this.loadAllSpecializations(); } catch (e) { @@ -90,7 +106,13 @@ export class DegreeProgramDetailsPageComponent { } this.savingProgram.set(true); try { - await firstValueFrom(this.degreeProgramsService.updateDegreeProgram(prog.degreeProgramId, { name: nameVal, responsibleUserId: userIdVal })); + await firstValueFrom( + this.degreeProgramsService.updateDegreeProgram(prog.degreeProgramId, { + name: nameVal, + responsibleUserId: userIdVal, + examinationBoardId: this.programExaminationBoardId() ?? undefined + }) + ); this.messageService.add({ severity: 'success', summary: 'Updated', detail: 'Program details saved.' }); await this.loadProgram(prog.degreeProgramId); } catch (e) { diff --git a/Client/src/app/pages/admin/examination-boards/examination-board-detail-page.component.html b/Client/src/app/pages/admin/examination-boards/examination-board-detail-page.component.html new file mode 100644 index 00000000..26cffa5b --- /dev/null +++ b/Client/src/app/pages/admin/examination-boards/examination-board-detail-page.component.html @@ -0,0 +1,74 @@ + + +@if (board(); as b) { +

Examination board – {{ b.name }}

+ +
+

Board details

+
+
+ + +
+ +
+
+ +

Manage users who belong to this examination board. Add members below or remove them from the list.

+ +
+
+ + +
+
+ +

Members

+ @if (b.members && b.members.length > 0) { + + + + Name + Email + Actions + + + + + {{ memberDisplayName(m) }} + {{ m.email ?? '—' }} + + + + + + + } @else { +

No members on this board yet. Use Add member above to assign users.

+ } +} @else if (!loading()) { +

Examination board not found.

+} @else { +

Loading…

+} + + +
+
+ + +
+
+ + + + +
diff --git a/Client/src/app/pages/admin/examination-boards/examination-board-detail-page.component.ts b/Client/src/app/pages/admin/examination-boards/examination-board-detail-page.component.ts new file mode 100644 index 00000000..f9b9bb01 --- /dev/null +++ b/Client/src/app/pages/admin/examination-boards/examination-board-detail-page.component.ts @@ -0,0 +1,172 @@ +import { Component, OnDestroy, inject, signal } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { TableModule } from 'primeng/table'; +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; +import { MessageService } from 'primeng/api'; +import { ToastModule } from 'primeng/toast'; +import { DialogModule } from 'primeng/dialog'; +import { TooltipModule } from 'primeng/tooltip'; +import { firstValueFrom } from 'rxjs'; +import { ExaminationBoardControllerService, type ExaminationBoardDTO, type ResponsibleUserDTO } from '../../../core/modules/openapi'; +import { BreadcrumbLabelsService } from '../../../components/breadcrumb/breadcrumb-labels.service'; +import { UsersSelectComponent } from '../../../components/users-select/users-select.component'; + +@Component({ + selector: 'app-examination-board-detail-page', + standalone: true, + imports: [FormsModule, TableModule, ButtonModule, InputTextModule, ToastModule, DialogModule, TooltipModule, UsersSelectComponent], + templateUrl: './examination-board-detail-page.component.html' +}) +export class ExaminationBoardDetailPageComponent implements OnDestroy { + private readonly route = inject(ActivatedRoute); + private readonly examinationBoardsService = inject(ExaminationBoardControllerService); + private readonly messageService = inject(MessageService); + private readonly breadcrumbLabels = inject(BreadcrumbLabelsService); + + board = signal(null); + loading = signal(true); + saving = signal(false); + + editName = ''; + userIdToAdd: string | null = null; + addMemberDialogVisible = signal(false); + + constructor() { + this.route.paramMap.subscribe((params) => { + const id = params.get('id'); + if (id != null) { + void this.loadBoard(Number(id)); + } else { + this.board.set(null); + } + }); + } + + ngOnDestroy(): void { + this.breadcrumbLabels.examinationBoardName.set(null); + } + + async loadBoard(id: number) { + this.loading.set(true); + this.breadcrumbLabels.examinationBoardName.set(null); + try { + const b = await firstValueFrom(this.examinationBoardsService.getExaminationBoard(id)); + this.board.set(b); + this.editName = b.name ?? ''; + this.breadcrumbLabels.examinationBoardName.set(b.name ?? null); + } catch { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Examination board not found.' }); + this.board.set(null); + this.breadcrumbLabels.examinationBoardName.set(null); + } finally { + this.loading.set(false); + } + } + + memberDisplayName(m: ResponsibleUserDTO): string { + const name = [m.firstName, m.lastName].filter(Boolean).join(' ').trim(); + if (name) return name; + return (m.email ?? m.userId ?? '—').trim(); + } + + currentMemberUserIds(): string[] { + return (this.board()?.members ?? []).map((m) => m.userId!).filter(Boolean); + } + + openAddMemberDialog() { + this.userIdToAdd = null; + this.addMemberDialogVisible.set(true); + } + + onAddMemberDialogVisibleChange(visible: boolean) { + this.addMemberDialogVisible.set(visible); + if (!visible) { + this.userIdToAdd = null; + } + } + + closeAddMemberDialog() { + this.onAddMemberDialogVisibleChange(false); + } + + async saveName() { + const b = this.board(); + const id = b?.examinationBoardId; + const name = this.editName.trim(); + if (!b || id == null || !name) { + this.messageService.add({ severity: 'warn', summary: 'Validation', detail: 'Name is required.' }); + return; + } + this.saving.set(true); + try { + await firstValueFrom( + this.examinationBoardsService.updateExaminationBoard(id, { + name, + userIds: this.currentMemberUserIds() + }) + ); + this.messageService.add({ severity: 'success', summary: 'Saved', detail: 'Board details updated.' }); + await this.loadBoard(id); + } catch { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to save name.' }); + } finally { + this.saving.set(false); + } + } + + async removeMember(userId: string) { + const b = this.board(); + const id = b?.examinationBoardId; + if (!b || id == null) return; + const next = this.currentMemberUserIds().filter((uid) => uid !== userId); + this.saving.set(true); + try { + await firstValueFrom( + this.examinationBoardsService.updateExaminationBoard(id, { + name: b.name ?? '', + userIds: next + }) + ); + this.messageService.add({ severity: 'success', summary: 'Updated', detail: 'Member removed.' }); + await this.loadBoard(id); + } catch { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to remove member.' }); + } finally { + this.saving.set(false); + } + } + + async addMember() { + const b = this.board(); + const id = b?.examinationBoardId; + const uid = this.userIdToAdd; + if (!b || id == null || !uid) { + this.messageService.add({ severity: 'warn', summary: 'Validation', detail: 'Select a user to add.' }); + return; + } + if (this.currentMemberUserIds().includes(uid)) { + this.messageService.add({ severity: 'warn', summary: 'Validation', detail: 'That user is already on this board.' }); + return; + } + const merged = [...new Set([...this.currentMemberUserIds(), uid])]; + this.saving.set(true); + try { + await firstValueFrom( + this.examinationBoardsService.updateExaminationBoard(id, { + name: b.name ?? '', + userIds: merged + }) + ); + this.messageService.add({ severity: 'success', summary: 'Updated', detail: 'Member added.' }); + this.userIdToAdd = null; + this.closeAddMemberDialog(); + await this.loadBoard(id); + } catch { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to add member.' }); + } finally { + this.saving.set(false); + } + } +} diff --git a/Client/src/app/pages/admin/examination-boards/examination-boards-page.component.html b/Client/src/app/pages/admin/examination-boards/examination-boards-page.component.html new file mode 100644 index 00000000..c4f5d702 --- /dev/null +++ b/Client/src/app/pages/admin/examination-boards/examination-boards-page.component.html @@ -0,0 +1,72 @@ +

Examination boards

+

Create examination boards and edit a board to manage members. Link boards to degree programs on each program’s detail page.

+ +
+

All boards

+ +
+ +@if (!loading()) { + + + + Name + Actions + + + + + + @if (b.examinationBoardId != null) { + + {{ b.name }} + + } @else { + {{ b.name }} + } + + + @if (b.examinationBoardId != null) { + + + + + } + + + + + + No examination boards yet. + + + +} @else { +

Loading…

+} + + +
+ + +
+ + + + +
diff --git a/Client/src/app/pages/admin/examination-boards/examination-boards-page.component.ts b/Client/src/app/pages/admin/examination-boards/examination-boards-page.component.ts new file mode 100644 index 00000000..c4058858 --- /dev/null +++ b/Client/src/app/pages/admin/examination-boards/examination-boards-page.component.ts @@ -0,0 +1,95 @@ +import { Component, inject, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { TableModule } from 'primeng/table'; +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; +import { DialogModule } from 'primeng/dialog'; +import { MessageService } from 'primeng/api'; +import { ToastModule } from 'primeng/toast'; +import { TooltipModule } from 'primeng/tooltip'; +import { firstValueFrom } from 'rxjs'; +import { ExaminationBoardControllerService, type ExaminationBoardSummaryDTO } from '../../../core/modules/openapi'; + +@Component({ + selector: 'app-examination-boards-page', + standalone: true, + imports: [RouterLink, FormsModule, TableModule, ButtonModule, InputTextModule, DialogModule, ToastModule, TooltipModule], + templateUrl: './examination-boards-page.component.html' +}) +export class ExaminationBoardsPageComponent { + private readonly examinationBoardsService = inject(ExaminationBoardControllerService); + private readonly messageService = inject(MessageService); + + boards = signal([]); + loading = signal(true); + saving = signal(false); + + createDialogVisible = signal(false); + createBoardName = ''; + + constructor() { + this.loadBoards(); + } + + async loadBoards() { + this.loading.set(true); + try { + const list = await firstValueFrom(this.examinationBoardsService.getAllExaminationBoards()); + this.boards.set(list ?? []); + } catch { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to load examination boards.' }); + this.boards.set([]); + } finally { + this.loading.set(false); + } + } + + openCreateDialog() { + this.createBoardName = ''; + this.createDialogVisible.set(true); + } + + onCreateDialogVisibleChange(visible: boolean) { + this.createDialogVisible.set(visible); + if (!visible) { + this.createBoardName = ''; + } + } + + closeCreateDialog() { + this.onCreateDialogVisibleChange(false); + } + + async createBoard() { + const name = this.createBoardName.trim(); + if (!name) { + this.messageService.add({ severity: 'warn', summary: 'Validation', detail: 'Enter a name.' }); + return; + } + this.saving.set(true); + try { + await firstValueFrom(this.examinationBoardsService.createExaminationBoard({ name })); + this.messageService.add({ severity: 'success', summary: 'Created', detail: 'Examination board created.' }); + this.closeCreateDialog(); + await this.loadBoards(); + } catch { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to create examination board.' }); + } finally { + this.saving.set(false); + } + } + + async deleteBoard(board: ExaminationBoardSummaryDTO) { + const id = board.examinationBoardId; + if (id == null) return; + if (!confirm(`Delete examination board "${board.name}"?`)) return; + try { + await firstValueFrom(this.examinationBoardsService.deleteExaminationBoard(id)); + this.messageService.add({ severity: 'success', summary: 'Deleted', detail: 'Examination board deleted.' }); + await this.loadBoards(); + } catch { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to delete examination board.' }); + } + } +} diff --git a/Client/src/app/pages/index/index.component.html b/Client/src/app/pages/index/index.component.html index 24a3f946..f485b929 100644 --- a/Client/src/app/pages/index/index.component.html +++ b/Client/src/app/pages/index/index.component.html @@ -6,8 +6,10 @@ Sign in required -

Log in to access the Module Management application.

- +

Sign in to access the Module Management application.

+
+ +
@@ -38,6 +40,10 @@

Welcome, {{ user()?.firstName }} {{ user

Manage degree programs and their areas of specialization.

+ +

Create examination boards, assign members, and link them to degree programs.

+ +
} @if (isProfessor()) { diff --git a/Client/src/app/pages/index/index.component.ts b/Client/src/app/pages/index/index.component.ts index 968daf74..63ac1578 100644 --- a/Client/src/app/pages/index/index.component.ts +++ b/Client/src/app/pages/index/index.component.ts @@ -4,12 +4,13 @@ import { ButtonModule } from 'primeng/button'; import { DividerModule } from 'primeng/divider'; import { PanelModule } from 'primeng/panel'; import { SecurityStore } from '../../core/security/security-store.service'; +import { SignInComponent } from '../../components/sign-in/sign-in.component'; import { isAdminRole, isProfessorRole, isReviewerRole } from '../../core/shared/user-role.utils'; @Component({ selector: 'index-component', standalone: true, - imports: [RouterModule, ButtonModule, DividerModule, PanelModule], + imports: [RouterModule, ButtonModule, DividerModule, PanelModule, SignInComponent], templateUrl: './index.component.html' }) export class IndexComponent { diff --git a/Client/src/app/pages/module-version-edit/module-version-edit.component.ts b/Client/src/app/pages/module-version-edit/module-version-edit.component.ts index 6862ac38..0b43a26a 100644 --- a/Client/src/app/pages/module-version-edit/module-version-edit.component.ts +++ b/Client/src/app/pages/module-version-edit/module-version-edit.component.ts @@ -72,7 +72,7 @@ export class ModuleVersionEditComponent extends ProposalBaseComponent { this.breadcrumbLabels.proposalTitle.set(response?.titleEng ?? null); this.breadcrumbLabels.versionLabel.set(version != null ? `Version ${version}` : null); }, - error: (err: HttpErrorResponse) => this.error.set(err.error), + error: (err: HttpErrorResponse) => this.showErrorAsToast(err), complete: () => { this.moduleLoading = false; this.loading.set(this.moduleLoading && this.feedbackLoading); @@ -84,7 +84,7 @@ export class ModuleVersionEditComponent extends ProposalBaseComponent { this.feedbackLoading = true; this.moduleVersionService.getPreviousModuleVersionFeedback(previousModuleVersionId).subscribe({ next: (response: Array) => this.feedbacks.set([...response]), - error: (err: HttpErrorResponse) => this.error.set(err.error), + error: (err: HttpErrorResponse) => this.showErrorAsToast(err), complete: () => { this.feedbackLoading = false; this.loading.set(this.moduleLoading && this.feedbackLoading); @@ -95,7 +95,6 @@ export class ModuleVersionEditComponent extends ProposalBaseComponent { override onSubmit(): void { if (this.moduleVersionId == null) return; this.loading.set(true); - this.error.set(null); const rawAssignments = this.assignments().filter((a) => a.degreeProgramId != null && a.degreeProgramSpecializationId != null); const degreeProgramAssignments: ModuleDegreeProgramAssignmentDTO[] = rawAssignments.map((a) => ({ degreeProgramId: a.degreeProgramId!, @@ -112,7 +111,7 @@ export class ModuleVersionEditComponent extends ProposalBaseComponent { // Step-1 changes can invalidate previous feedbacks; refresh so the UI reflects it. this.fetchPreviousModuleVersionFeedback(this.moduleVersionId); }, - error: (err: HttpErrorResponse) => this.error.set(err.error), + error: (err: HttpErrorResponse) => this.showErrorAsToast(err, 'Failed to update proposal'), complete: () => this.loading.set(false) }); } diff --git a/Client/src/app/pages/module-version-view/module-version-view.component.html b/Client/src/app/pages/module-version-view/module-version-view.component.html index 180e88b7..49522ec7 100644 --- a/Client/src/app/pages/module-version-view/module-version-view.component.html +++ b/Client/src/app/pages/module-version-view/module-version-view.component.html @@ -87,7 +87,7 @@

Overview of '{{ moduleVersionDto.titleEng }}' - Version {{ @if (currentStepIndex() === 1) { -

First submission: request feedback from program coordinators and area coordinators (one per chosen degree program/area).

+

Request feedback from program coordinators and area coordinators (one per chosen degree program/area).

@if (coordinatorFeedbacksForStep1().feedbacks.length > 0) {

Coordinator feedback status {{ coordinatorFeedbacksForStep1().fromPrevious ? '(from previous version)' : '(this version)' }}

@@ -102,7 +102,12 @@

Overview of '{{ moduleVersionDto.titleEng }}' - Version {{ />

@if (fb.feedbackStatus === 'REJECTED' && fb.rejectionComment) { - + } } @@ -229,16 +234,15 @@

Overview of '{{ moduleVersionDto.titleEng }}' - Version {{ } @if (currentStepIndex() === 6) { - -

- Second submission: after all steps are complete and coordinator feedback is accepted, you can submit for feedback from quality management, academic program advisor, and - examination board. -

- @if (moduleVersionDto.feedbacks?.length) { -
-

Feedback status

+ +

Request feedback from the examination boards of the chosen degree programs.

+ @if (examinationBoardMemberFeedbacksForStep6().feedbacks.length > 0) { +
+

+ Examination board feedback status {{ examinationBoardMemberFeedbacksForStep6().fromPrevious ? '(from previous version)' : '(this version)' }} +

    - @for (fb of moduleVersionDto.feedbacks; track fb.feedbackId) { + @for (fb of examinationBoardMemberFeedbacksForStep6().feedbacks; track fb.feedbackId) {
  • {{ fb | feedbackAuthorDisplay }}: @@ -248,7 +252,12 @@

    Overview of '{{ moduleVersionDto.titleEng }}' - Version {{ />

    @if (fb.feedbackStatus === 'REJECTED' && fb.rejectionComment) { - + }
  • } diff --git a/Client/src/app/pages/module-version-view/module-version-view.component.ts b/Client/src/app/pages/module-version-view/module-version-view.component.ts index ba614c5b..746b0cd6 100644 --- a/Client/src/app/pages/module-version-view/module-version-view.component.ts +++ b/Client/src/app/pages/module-version-view/module-version-view.component.ts @@ -5,6 +5,10 @@ import { RouterModule, ActivatedRoute } from '@angular/router'; import { ModuleVersionControllerService, ModuleVersionViewDTO, ModuleVersionViewFeedbackDTO } from '../../core/modules/openapi'; import { BreadcrumbLabelsService } from '../../components/breadcrumb/breadcrumb-labels.service'; import { ModuleEditStepperComponent } from '../../components/module-edit-stepper/module-edit-stepper.component'; +import { + filterCoordinatorFeedbacksForAssignments, + filterExaminationBoardMemberFeedbacks +} from '../../components/module-edit-stepper/coordinator-feedback.util'; import { MODULE_EDIT_STEPS, StepperStatus } from '../../components/module-edit-stepper/module-edit-steps.config'; import { FeedbackAuthorDisplayPipe } from '../../pipes/feedbackAuthorDisplay.pipe'; import { FeedbackStatusPipe } from '../../pipes/feedbackStatus.pipe'; @@ -16,6 +20,10 @@ import { MessageModule } from 'primeng/message'; import { CardModule } from 'primeng/card'; import { FeedbackMessageComponent } from '../../components/feedback-message/feedback-message.component'; import { PanelModule } from 'primeng/panel'; +import { + coordinatorFeedbackStepStatus, + examinationBoardFeedbackStepStatus +} from '../../components/module-edit-stepper/module-version-stepper-status.util'; export interface ModuleField { key: keyof ModuleVersionViewDTO; @@ -61,29 +69,32 @@ export class ModuleVersionViewComponent { readonly MODULE_EDIT_STEPS = MODULE_EDIT_STEPS; currentStepIndex = signal(0); - /** Coordinator feedbacks for this version (for current assignments). From moduleVersionDto().feedbacks. */ - coordinatorFeedbacksForCurrentAssignments = computed(() => { - const dto = this.moduleVersionDto(); - const feedbacks = dto?.feedbacks ?? []; - const coordinator = feedbacks.filter((f) => f.feedbackRole == null); - const specIds = new Set(dto?.degreeProgramAssignments?.map((a) => a.degreeProgramSpecializationId).filter((id): id is number => id != null) ?? []); - if (specIds.size === 0) return coordinator; - return coordinator.filter((f) => f.degreeProgramSpecializationId != null && specIds.has(f.degreeProgramSpecializationId)); - }); - /** Previous module version feedback (all non-invalidated for proposal). Fetched when loading; used when current version has no feedbacks yet. */ previousVersionFeedbacks = signal([]); - /** Coordinator feedbacks to show in step 1: current version if any, otherwise previous version feedback (so latest MV still shows status). */ + /** + * Coordinator feedback for step 1: current module version DTO if any rows match; otherwise + * {@code previousVersionFeedbacks} (latest view may not embed coordinator rows yet). + */ coordinatorFeedbacksForStep1 = computed(() => { - const fromCurrent = this.coordinatorFeedbacksForCurrentAssignments(); - if (fromCurrent.length > 0) return { feedbacks: fromCurrent, fromPrevious: false }; - const prev = this.previousVersionFeedbacks(); - const coordinator = prev.filter((f) => f.feedbackRole == null); const dto = this.moduleVersionDto(); - const specIds = new Set(dto?.degreeProgramAssignments?.map((a) => a.degreeProgramSpecializationId).filter((id): id is number => id != null) ?? []); - const filtered = specIds.size === 0 ? coordinator : coordinator.filter((f) => f.degreeProgramSpecializationId != null && specIds.has(f.degreeProgramSpecializationId)); - return { feedbacks: filtered, fromPrevious: true }; + const fromCurrent = filterCoordinatorFeedbacksForAssignments(dto?.feedbacks ?? [], dto); + if (fromCurrent.length > 0) { + return { feedbacks: fromCurrent, fromPrevious: false }; + } + const fromPrev = filterCoordinatorFeedbacksForAssignments(this.previousVersionFeedbacks(), dto); + return { feedbacks: fromPrev, fromPrevious: true }; + }); + + /** Examination-board member feedback for step 6 (current DTO vs previous-version list). */ + examinationBoardMemberFeedbacksForStep6 = computed(() => { + const dto = this.moduleVersionDto(); + const fromDto = filterExaminationBoardMemberFeedbacks(dto?.feedbacks ?? []); + if (fromDto.length > 0) { + return { feedbacks: fromDto, fromPrevious: false }; + } + const fromPrev = filterExaminationBoardMemberFeedbacks(this.previousVersionFeedbacks()); + return { feedbacks: fromPrev, fromPrevious: true }; }); moduleFields: ModuleField[] = [ @@ -130,25 +141,19 @@ export class ModuleVersionViewComponent { { key: 'lvSwsLecturerEng', label: 'Lecturer', section: 'content', isLongText: true, feedbackKey: 'lvSwsLecturerFeedback' } ]; + /** Read-only stepper: feedback steps follow {@link ModuleVersionViewDTO.status} only; other steps from DTO field presence. */ stepStatuses = computed(() => { const dto = this.moduleVersionDto(); if (!dto) return MODULE_EDIT_STEPS.map(() => StepperStatus.Default); + const workflowStatus = dto.status; return MODULE_EDIT_STEPS.map((step, index) => { - if (step.id === 'feedbacks') return StepperStatus.Default; if (step.id === 'submit-coordinator-feedback') { - const feedbacks = this.coordinatorFeedbacksForStep1().feedbacks; - if (feedbacks.length === 0) return StepperStatus.Default; - const pending = ModuleVersionViewFeedbackDTO.FeedbackStatusEnum.PendingFeedback; - const approved = ModuleVersionViewFeedbackDTO.FeedbackStatusEnum.Approved; - const rejected = ModuleVersionViewFeedbackDTO.FeedbackStatusEnum.Rejected; - if (feedbacks.some((fb) => fb.feedbackStatus === rejected)) return StepperStatus.Rejected; - if (feedbacks.some((fb) => (fb.feedbackStatus ?? pending) === pending)) return StepperStatus.Pending; - if (feedbacks.every((fb) => fb.feedbackStatus === approved)) return StepperStatus.Completed; - return StepperStatus.FeedbackGiven; + return coordinatorFeedbackStepStatus(workflowStatus); + } + if (step.id === 'submit-examination-board-feedback') { + return examinationBoardFeedbackStepStatus(workflowStatus); } - - if (step.id === 'submit-full-feedback') return StepperStatus.Default; const keys = MODULE_EDIT_STEPS[index].controlNames as (keyof ModuleVersionViewDTO)[]; if (!keys?.length) return StepperStatus.Default; diff --git a/Client/src/app/pages/proposal-create/proposal-create.component.ts b/Client/src/app/pages/proposal-create/proposal-create.component.ts index e7cc363d..8893b46a 100644 --- a/Client/src/app/pages/proposal-create/proposal-create.component.ts +++ b/Client/src/app/pages/proposal-create/proposal-create.component.ts @@ -50,12 +50,11 @@ export class ProposalCreateComponent extends ProposalBaseComponent { } override async onSubmit(): Promise { - if (!this.proposalForm.valid) return; const rawAssignments = this.assignments().filter((a) => a.degreeProgramId != null && a.degreeProgramSpecializationId != null); const programIds = rawAssignments.map((a) => a.degreeProgramId!); if (new Set(programIds).size !== programIds.length) { - this.error.set('Each degree program can only be assigned once.'); + this.showErrorAsToast('Each degree program can only be assigned once.'); return; } const degreeProgramAssignments: ModuleDegreeProgramAssignmentDTO[] = rawAssignments.map((a) => ({ @@ -68,7 +67,6 @@ export class ProposalCreateComponent extends ProposalBaseComponent { degreeProgramAssignments }; this.loading.set(true); - this.error.set(null); try { const res = await firstValueFrom(this.proposalService.createProposal(body)); const proposalId = res.proposalId; @@ -76,10 +74,10 @@ export class ProposalCreateComponent extends ProposalBaseComponent { if (proposalId != null && moduleVersionId != null) { await this.router.navigate(['/proposals', proposalId, 'version', moduleVersionId, 'edit'], { queryParams: { created: true } }); } else { - this.error.set('Unexpected response from server.'); + this.showErrorAsToast('Unexpected response from server.'); } } catch (err: unknown) { - this.error.set(err instanceof HttpErrorResponse ? (err.error ?? err.message) : 'Failed to create proposal.'); + this.showErrorAsToast(err, 'Failed to create proposal.'); } finally { this.loading.set(false); } diff --git a/Client/src/app/pipes/feedbackAuthorDisplay.pipe.ts b/Client/src/app/pipes/feedbackAuthorDisplay.pipe.ts index 206d8d0b..ff4aa532 100644 --- a/Client/src/app/pipes/feedbackAuthorDisplay.pipe.ts +++ b/Client/src/app/pipes/feedbackAuthorDisplay.pipe.ts @@ -1,11 +1,11 @@ import { Pipe, PipeTransform } from '@angular/core'; import { Feedback } from '../core/modules/openapi'; -/** Feedback with optional name/area (ModuleVersionViewFeedbackDTO) or role-only (Feedback). */ +/** ModuleVersionViewFeedbackDTO, Feedback, or similar with optional display fields. */ type FeedbackAuthorInput = { requestedFromUserName?: string | null; requestedFromSpecializationName?: string | null; - feedbackRole?: Feedback.RequiredRoleEnum | null; + examinationBoardName?: string | null; requiredRole?: Feedback.RequiredRoleEnum | null; }; @@ -19,14 +19,30 @@ const ROLE_LABELS: Record = { PROFESSOR: 'Professor' }; +/** + * Same pattern for both flows: {@code Label: scope — reviewer name} (scope only when no name). + * Examination board uses label "Examination board"; coordinators use "Coordinator". + */ @Pipe({ name: 'feedbackAuthorDisplay', standalone: true }) export class FeedbackAuthorDisplayPipe implements PipeTransform { transform(fb: FeedbackAuthorInput | null | undefined): string { if (!fb) return ''; - const name = fb.requestedFromUserName; - const role = fb.feedbackRole ?? fb.requiredRole; - const area = fb.requestedFromSpecializationName ?? (role ? ROLE_LABELS[role] ?? role : null); - const combined = name && [name, area && `(${area})`].filter(Boolean).join(' '); - return combined ?? ROLE_LABELS[role ?? ''] ?? role ?? '-'; + const name = (fb.requestedFromUserName ?? '').trim(); + const role = fb.requiredRole; + + const hasBoard = fb.examinationBoardName != null && fb.examinationBoardName.trim() !== ''; + if (hasBoard) { + const board = `Examination board: ${fb.examinationBoardName!.trim()}`; + return name ? `${board} — ${name}` : board; + } + + const scope = + (fb.requestedFromSpecializationName ?? '').trim() || + (role ? ROLE_LABELS[role] ?? String(role) : ''); + if (scope) { + const head = `Coordinator: ${scope}`; + return name ? `${head} — ${name}` : head; + } + return name || (ROLE_LABELS[role ?? ''] ?? role) || '-'; } } diff --git a/Client/src/app/pipes/moduleVersionStatus.pipe.ts b/Client/src/app/pipes/moduleVersionStatus.pipe.ts index f8b06bad..3a0773b1 100644 --- a/Client/src/app/pipes/moduleVersionStatus.pipe.ts +++ b/Client/src/app/pipes/moduleVersionStatus.pipe.ts @@ -14,37 +14,58 @@ export class ModuleVersionStatusPipe implements PipeTransform { severity: Tag['severity']; } { switch (status) { - case ModuleVersionCompactDTO.StatusEnum.PendingFirstSubmission: + case ModuleVersionCompactDTO.StatusEnum.WaitingForCoordinatorsSubmission: return { - text: 'Pending first submission', + text: 'Waiting for coordinator submission', normalColor: 'bg-gray-500', fadedColor: 'bg-gray-300', severity: 'secondary' }; - case ModuleVersionCompactDTO.StatusEnum.PendingCoordinatorFeedback: + case ModuleVersionCompactDTO.StatusEnum.PendingCoordinatorsFeedback: return { - text: 'Pending coordinator feedback', + text: 'Pending coordinators feedback', normalColor: 'bg-yellow-500', fadedColor: 'bg-yellow-300', severity: 'warn' }; - case ModuleVersionCompactDTO.StatusEnum.CoordinatorFeedbackGiven: + case ModuleVersionCompactDTO.StatusEnum.CoordinatorsFeedbackGiven: return { - text: 'Coordinator feedback given', + text: 'Coordinators feedback given', normalColor: 'bg-blue-500', fadedColor: 'bg-blue-300', severity: 'info' }; - case ModuleVersionCompactDTO.StatusEnum.PendingFullSubmission: + case ModuleVersionCompactDTO.StatusEnum.WaitingForExaminationBoardSubmission: return { - text: 'Pending full submission', + text: 'Waiting for examination board submission', normalColor: 'bg-gray-500', fadedColor: 'bg-gray-300', severity: 'secondary' }; - case ModuleVersionCompactDTO.StatusEnum.PendingFullFeedback: + case ModuleVersionCompactDTO.StatusEnum.PendingExaminationBoardFeedback: return { - text: 'Pending full feedback', + text: 'Pending examination board feedback', + normalColor: 'bg-yellow-500', + fadedColor: 'bg-yellow-300', + severity: 'warn' + }; + case ModuleVersionCompactDTO.StatusEnum.ExaminationBoardFeedbackGiven: + return { + text: 'Examination board feedback given', + normalColor: 'bg-blue-500', + fadedColor: 'bg-blue-300', + severity: 'info' + }; + case ModuleVersionCompactDTO.StatusEnum.WaitingForQualityManagementSubmission: + return { + text: 'Waiting for quality management submission', + normalColor: 'bg-gray-500', + fadedColor: 'bg-gray-300', + severity: 'secondary' + }; + case ModuleVersionCompactDTO.StatusEnum.PendingQualityManagementFeedback: + return { + text: 'Pending quality management feedback', normalColor: 'bg-yellow-500', fadedColor: 'bg-yellow-300', severity: 'warn' @@ -63,7 +84,8 @@ export class ModuleVersionStatusPipe implements PipeTransform { fadedColor: 'bg-sky-300', severity: 'info' }; - case ModuleVersionCompactDTO.StatusEnum.Rejected: + case ModuleVersionCompactDTO.StatusEnum.RejectedAtCoordinatorsFeedback: + case ModuleVersionCompactDTO.StatusEnum.RejectedAtExaminationBoardFeedback: return { text: 'Rejected', normalColor: 'bg-red-500', diff --git a/Client/src/app/pipes/proposalStatus.pipe.ts b/Client/src/app/pipes/proposalStatus.pipe.ts index f809b476..66688881 100644 --- a/Client/src/app/pipes/proposalStatus.pipe.ts +++ b/Client/src/app/pipes/proposalStatus.pipe.ts @@ -5,21 +5,28 @@ import { Tag } from 'primeng/tag'; export class StatusDisplayPipe implements PipeTransform { transform(status: ProposalViewDTO.StatusEnum): { text: string; severity: Tag['severity'] } { switch (status) { - case 'PENDING_FIRST_SUBMISSION': - return { text: 'Pending first submission', severity: 'secondary' }; - case 'PENDING_COORDINATOR_FEEDBACK': - return { text: 'Pending coordinator feedback', severity: 'warn' }; - case 'COORDINATOR_FEEDBACK_GIVEN': - return { text: 'Coordinator feedback given', severity: 'info' }; - case 'PENDING_FULL_SUBMISSION': - return { text: 'Pending full submission', severity: 'secondary' }; - case 'PENDING_FULL_FEEDBACK': - return { text: 'Pending full feedback', severity: 'warn' }; + case 'WAITING_FOR_COORDINATORS_SUBMISSION': + return { text: 'Waiting for coordinator submission', severity: 'secondary' }; + case 'PENDING_COORDINATORS_FEEDBACK': + return { text: 'Pending coordinators feedback', severity: 'warn' }; + case 'COORDINATORS_FEEDBACK_GIVEN': + return { text: 'Coordinators feedback given', severity: 'info' }; + case 'WAITING_FOR_EXAMINATION_BOARD_SUBMISSION': + return { text: 'Waiting for examination board submission', severity: 'secondary' }; + case 'PENDING_EXAMINATION_BOARD_FEEDBACK': + return { text: 'Pending examination board feedback', severity: 'warn' }; + case 'EXAMINATION_BOARD_FEEDBACK_GIVEN': + return { text: 'Examination board feedback given', severity: 'info' }; + case 'WAITING_FOR_QUALITY_MANAGEMENT_SUBMISSION': + return { text: 'Waiting for quality management submission', severity: 'secondary' }; + case 'PENDING_QUALITY_MANAGEMENT_FEEDBACK': + return { text: 'Pending quality management feedback', severity: 'warn' }; case 'ACCEPTED': return { text: 'Accepted', severity: 'success' }; case 'REQUIRES_REVIEW': return { text: 'Requires Review', severity: 'info' }; - case 'REJECTED': + case 'REJECTED_AT_COORDINATORS_FEEDBACK': + case 'REJECTED_AT_EXAMINATION_BOARD_FEEDBACK': return { text: 'Rejected', severity: 'danger' }; default: return { text: status ?? '', severity: 'secondary' }; @@ -31,21 +38,28 @@ export class StatusDisplayPipe implements PipeTransform { export class StatusInfoPipeline implements PipeTransform { transform(status: ProposalViewDTO.StatusEnum): string { switch (status) { - case 'PENDING_FIRST_SUBMISSION': - return 'This module proposal is pending submission. Please complete step 1 (basic information and degree program assignments) and submit for coordinator feedback.'; - case 'PENDING_COORDINATOR_FEEDBACK': - return 'Submitted for coordinator feedback. Program and area coordinators are reviewing. You can cancel and resubmit if needed.'; - case 'COORDINATOR_FEEDBACK_GIVEN': - return 'All coordinators have responded. None gave approval, but at least one gave feedback without approval; review their comments and update your module if needed.'; - case 'PENDING_FULL_SUBMISSION': - return 'Coordinator feedback was accepted. Complete all steps and submit for full feedback (quality management, program advisor, examination board) when ready.'; - case 'PENDING_FULL_FEEDBACK': - return 'This module proposal is submitted and waiting for review. You can cancel the submission if needed.'; + case 'WAITING_FOR_COORDINATORS_SUBMISSION': + return 'Complete step 1 and submit for coordinator feedback when ready.'; + case 'PENDING_COORDINATORS_FEEDBACK': + return 'Submitted for coordinator feedback. Program and area coordinators are reviewing.'; + case 'COORDINATORS_FEEDBACK_GIVEN': + return 'All coordinators have responded; at least one response needs your attention before approval.'; + case 'WAITING_FOR_EXAMINATION_BOARD_SUBMISSION': + return 'Coordinator feedback was accepted. Complete all steps, then submit for examination board feedback.'; + case 'PENDING_EXAMINATION_BOARD_FEEDBACK': + return 'Waiting for examination board feedback.'; + case 'EXAMINATION_BOARD_FEEDBACK_GIVEN': + return 'Examination board has responded; review feedback before requesting quality management feedback.'; + case 'WAITING_FOR_QUALITY_MANAGEMENT_SUBMISSION': + return 'Examination board feedback is complete. Submit for quality management feedback.'; + case 'PENDING_QUALITY_MANAGEMENT_FEEDBACK': + return 'Waiting for quality management feedback.'; case 'ACCEPTED': return 'This module is approved.'; case 'REQUIRES_REVIEW': return 'This module proposal requires your review. Create a new module version and update by the rejection feedback.'; - case 'REJECTED': + case 'REJECTED_AT_COORDINATORS_FEEDBACK': + case 'REJECTED_AT_EXAMINATION_BOARD_FEEDBACK': return 'This proposal was rejected. Create a new module version to resubmit.'; default: return status ?? ''; diff --git a/Client/tsconfig.app.json b/Client/tsconfig.app.json index 3775b37e..264f459b 100644 --- a/Client/tsconfig.app.json +++ b/Client/tsconfig.app.json @@ -6,10 +6,10 @@ "outDir": "./out-tsc/app", "types": [] }, - "files": [ - "src/main.ts" - ], "include": [ - "src/**/*.d.ts" + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" ] } diff --git a/Server/src/main/java/modulemanagement/ls1/controllers/ExaminationBoardController.java b/Server/src/main/java/modulemanagement/ls1/controllers/ExaminationBoardController.java new file mode 100644 index 00000000..6889d453 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/controllers/ExaminationBoardController.java @@ -0,0 +1,51 @@ +package modulemanagement.ls1.controllers; + +import jakarta.validation.Valid; +import modulemanagement.ls1.dtos.*; +import modulemanagement.ls1.services.ExaminationBoardService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/examination-boards") +@PreAuthorize("hasRole('ADMIN')") +public class ExaminationBoardController { + + private final ExaminationBoardService examinationBoardService; + + public ExaminationBoardController(ExaminationBoardService examinationBoardService) { + this.examinationBoardService = examinationBoardService; + } + + @GetMapping + public ResponseEntity> getAllExaminationBoards() { + return ResponseEntity.ok(examinationBoardService.getAllExaminationBoards()); + } + + @GetMapping("/{id}") + public ResponseEntity getExaminationBoard(@PathVariable Long id) { + return ResponseEntity.ok(examinationBoardService.getExaminationBoard(id)); + } + + @PostMapping + public ResponseEntity createExaminationBoard( + @Valid @RequestBody CreateExaminationBoardDTO dto) { + return ResponseEntity.ok(examinationBoardService.createExaminationBoard(dto)); + } + + @PutMapping("/{id}") + public ResponseEntity updateExaminationBoard( + @PathVariable Long id, + @Valid @RequestBody UpdateExaminationBoardDTO dto) { + return ResponseEntity.ok(examinationBoardService.updateExaminationBoard(id, dto)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteExaminationBoard(@PathVariable Long id) { + examinationBoardService.deleteExaminationBoard(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/Server/src/main/java/modulemanagement/ls1/controllers/ProposalController.java b/Server/src/main/java/modulemanagement/ls1/controllers/ProposalController.java index 57c06a95..fac1a50c 100644 --- a/Server/src/main/java/modulemanagement/ls1/controllers/ProposalController.java +++ b/Server/src/main/java/modulemanagement/ls1/controllers/ProposalController.java @@ -42,11 +42,11 @@ public ResponseEntity requestCoordinatorsFeedback(@CurrentUser return ResponseEntity.ok(proposalDto); } - @PostMapping(value = "/request-full-feedback/{proposalId}") + @PostMapping(value = "/request-examination-board-feedback/{proposalId}") @PreAuthorize("hasAnyRole('PROFESSOR')") - public ResponseEntity requestFullFeedback(@CurrentUser User user, + public ResponseEntity requestExaminationBoardFeedback(@CurrentUser User user, @PathVariable Long proposalId) { - var proposalDto = proposalService.requestFullFeedback(proposalId, user.getUserId()); + var proposalDto = proposalService.requestExaminationBoardFeedback(proposalId, user.getUserId()); return ResponseEntity.ok(proposalDto); } diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/CreateExaminationBoardDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/CreateExaminationBoardDTO.java new file mode 100644 index 00000000..cd09ba9e --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/CreateExaminationBoardDTO.java @@ -0,0 +1,12 @@ +package modulemanagement.ls1.dtos; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class CreateExaminationBoardDTO { + @NotBlank + @Size(max = 512) + private String name; +} diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/DegreeProgramDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/DegreeProgramDTO.java index d05a4005..abe666df 100644 --- a/Server/src/main/java/modulemanagement/ls1/dtos/DegreeProgramDTO.java +++ b/Server/src/main/java/modulemanagement/ls1/dtos/DegreeProgramDTO.java @@ -18,6 +18,8 @@ public class DegreeProgramDTO { private List degreeProgramSpecializations; + private ExaminationBoardSummaryDTO examinationBoard; + public static DegreeProgramDTO fromDegreeProgram(DegreeProgram program) { DegreeProgramDTO dto = new DegreeProgramDTO(); dto.setDegreeProgramId(program.getDegreeProgramId()); @@ -25,6 +27,7 @@ public static DegreeProgramDTO fromDegreeProgram(DegreeProgram program) { if (program.getResponsibleUser() != null) { dto.setResponsibleUser(ResponsibleUserDTO.fromUser(program.getResponsibleUser())); } + dto.setExaminationBoard(ExaminationBoardSummaryDTO.fromEntity(program.getExaminationBoard())); if (program.getDegreeProgramSpecializations() != null) { dto.setDegreeProgramSpecializations(program.getDegreeProgramSpecializations().stream() .map(DegreeProgramSpecializationDTO::fromEntity).collect(Collectors.toList())); diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ExaminationBoardDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ExaminationBoardDTO.java new file mode 100644 index 00000000..978d5f28 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/ExaminationBoardDTO.java @@ -0,0 +1,26 @@ +package modulemanagement.ls1.dtos; + +import lombok.Data; +import modulemanagement.ls1.models.ExaminationBoard; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Data +public class ExaminationBoardDTO { + private Long examinationBoardId; + private String name; + private List members = new ArrayList<>(); + + public static ExaminationBoardDTO fromEntity(ExaminationBoard board) { + ExaminationBoardDTO dto = new ExaminationBoardDTO(); + dto.setExaminationBoardId(board.getExaminationBoardId()); + dto.setName(board.getName()); + if (board.getMembers() != null) { + dto.setMembers(board.getMembers().stream().map(ResponsibleUserDTO::fromUser) + .collect(Collectors.toList())); + } + return dto; + } +} diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ExaminationBoardSummaryDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ExaminationBoardSummaryDTO.java new file mode 100644 index 00000000..5792b31c --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/ExaminationBoardSummaryDTO.java @@ -0,0 +1,19 @@ +package modulemanagement.ls1.dtos; + +import lombok.Data; +import modulemanagement.ls1.models.ExaminationBoard; + +@Data +public class ExaminationBoardSummaryDTO { + private Long examinationBoardId; + private String name; + + public static ExaminationBoardSummaryDTO fromEntity(ExaminationBoard board) { + if (board == null) + return null; + ExaminationBoardSummaryDTO dto = new ExaminationBoardSummaryDTO(); + dto.setExaminationBoardId(board.getExaminationBoardId()); + dto.setName(board.getName()); + return dto; + } +} diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/FeedbackCompactDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/FeedbackCompactDTO.java index 7acdd658..f1103272 100644 --- a/Server/src/main/java/modulemanagement/ls1/dtos/FeedbackCompactDTO.java +++ b/Server/src/main/java/modulemanagement/ls1/dtos/FeedbackCompactDTO.java @@ -13,6 +13,7 @@ public class FeedbackCompactDTO { private Long feedbackId; private String requestedFromUserName; private String requestedFromSpecializationName; + private String examinationBoardName; private UserRole requiredRole; private FeedbackStatus status; private Boolean invalidated; @@ -23,13 +24,19 @@ public static FeedbackCompactDTO from(Feedback f) { dto.setRequiredRole(f.getRequiredRole()); dto.setStatus(f.getStatus()); dto.setInvalidated(f.isInvalidated()); + if (f.getAssignedReviewer() != null) { + var ar = f.getAssignedReviewer(); + dto.setRequestedFromUserName(ar.getFirstName() + " " + ar.getLastName()); + } if (f.getDegreeProgramSpecialization() != null) { var spec = f.getDegreeProgramSpecialization(); dto.setRequestedFromSpecializationName(spec.getName()); - if (spec.getResponsibleUser() != null) { + if (dto.getRequestedFromUserName() == null && spec.getResponsibleUser() != null) { var resp = spec.getResponsibleUser(); dto.setRequestedFromUserName(resp.getFirstName() + " " + resp.getLastName()); } + } else if (f.getExaminationBoard() != null) { + dto.setExaminationBoardName(f.getExaminationBoard().getName()); } else if (f.getRequiredRole() != null && f.getFeedbackFrom() != null) { var user = f.getFeedbackFrom(); dto.setRequestedFromUserName(user.getFirstName() + " " + user.getLastName()); diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewFeedbackDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewFeedbackDTO.java index 39efb9fd..6710bdff 100644 --- a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewFeedbackDTO.java +++ b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewFeedbackDTO.java @@ -15,9 +15,11 @@ public class ModuleVersionViewFeedbackDTO { private String feedbackFromFirstName; private String feedbackFromLastName; private String rejectionComment; - private UserRole feedbackRole; + private UserRole requiredRole; private String requestedFromUserName; private String requestedFromSpecializationName; + private Long examinationBoardId; + private String examinationBoardName; private FeedbackStatus feedbackStatus; private LocalDateTime createdAt; private LocalDateTime submissionDate; @@ -59,21 +61,27 @@ public static ModuleVersionViewFeedbackDTO from(Feedback f) { dto.setFeedbackFromLastName(f.getFeedbackFrom().getLastName()); } dto.setRejectionComment(f.getComment()); - dto.setFeedbackRole(f.getRequiredRole()); + dto.setRequiredRole(f.getRequiredRole()); + if (f.getAssignedReviewer() != null) { + var ar = f.getAssignedReviewer(); + dto.setRequestedFromUserName(ar.getFirstName() + " " + ar.getLastName()); + } if (f.getDegreeProgramSpecialization() != null) { var spec = f.getDegreeProgramSpecialization(); dto.setRequestedFromSpecializationName(spec.getName()); - if (spec.getResponsibleUser() != null) { + dto.setDegreeProgramSpecializationId(spec.getDegreeProgramSpecializationId()); + if (f.getAssignedReviewer() == null && spec.getResponsibleUser() != null) { var resp = spec.getResponsibleUser(); dto.setRequestedFromUserName(resp.getFirstName() + " " + resp.getLastName()); } } + if (f.getExaminationBoard() != null) { + dto.setExaminationBoardId(f.getExaminationBoard().getExaminationBoardId()); + dto.setExaminationBoardName(f.getExaminationBoard().getName()); + } dto.setFeedbackStatus(f.getStatus()); dto.setCreatedAt(f.getCreatedAt()); dto.setSubmissionDate(f.getSubmissionDate()); - if (f.getDegreeProgramSpecialization() != null) { - dto.setDegreeProgramSpecializationId(f.getDegreeProgramSpecialization().getDegreeProgramSpecializationId()); - } dto.setTitleFeedback(f.getTitleFeedback()); dto.setTitleDeFeedback(f.getTitleDeFeedback()); dto.setBulletPointsFeedback(f.getBulletPointsFeedback()); diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/UpdateDegreeProgramDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/UpdateDegreeProgramDTO.java index dfd14230..8fd053d0 100644 --- a/Server/src/main/java/modulemanagement/ls1/dtos/UpdateDegreeProgramDTO.java +++ b/Server/src/main/java/modulemanagement/ls1/dtos/UpdateDegreeProgramDTO.java @@ -8,4 +8,5 @@ public class UpdateDegreeProgramDTO { private String name; private UUID responsibleUserId; + private Long examinationBoardId; } diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/UpdateExaminationBoardDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/UpdateExaminationBoardDTO.java new file mode 100644 index 00000000..6597586c --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/UpdateExaminationBoardDTO.java @@ -0,0 +1,21 @@ +package modulemanagement.ls1.dtos; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Data +public class UpdateExaminationBoardDTO { + + @NotBlank + @Size(max = 512) + private String name; + + @NotNull + private List userIds = new ArrayList<>(); +} diff --git a/Server/src/main/java/modulemanagement/ls1/enums/ModuleVersionStatus.java b/Server/src/main/java/modulemanagement/ls1/enums/ModuleVersionStatus.java index e42c86e0..c30b3fae 100644 --- a/Server/src/main/java/modulemanagement/ls1/enums/ModuleVersionStatus.java +++ b/Server/src/main/java/modulemanagement/ls1/enums/ModuleVersionStatus.java @@ -2,25 +2,37 @@ public enum ModuleVersionStatus { /** Not yet submitted for coordinator feedback (first submission). */ - PENDING_FIRST_SUBMISSION, + WAITING_FOR_COORDINATORS_SUBMISSION, /** * Submitted for coordinator feedback; waiting for program/area coordinators. */ - PENDING_COORDINATOR_FEEDBACK, + PENDING_COORDINATORS_FEEDBACK, /** * All coordinator responses are in; at least one gave non-approval feedback and * none approved yet. */ - COORDINATOR_FEEDBACK_GIVEN, + COORDINATORS_FEEDBACK_GIVEN, /** - * Coordinator feedback accepted; professor has not yet submitted for full + * Coordinator feedback accepted; not yet submitted for examination board * feedback. */ - PENDING_FULL_SUBMISSION, - /** Submitted for full feedback; waiting for QM, advisor, examination board. */ - PENDING_FULL_FEEDBACK, + WAITING_FOR_EXAMINATION_BOARD_SUBMISSION, + /** Submitted for examination board feedback; waiting for examination board. */ + PENDING_EXAMINATION_BOARD_FEEDBACK, + /** Examination board responses in; not all approved. */ + EXAMINATION_BOARD_FEEDBACK_GIVEN, + /** + * All examination board feedbacks approved; not yet submitted for quality + * management feedback. + */ + WAITING_FOR_QUALITY_MANAGEMENT_SUBMISSION, + /** Submitted for quality management feedback. */ + PENDING_QUALITY_MANAGEMENT_FEEDBACK, ACCEPTED, REQUIRES_REVIEW, - REJECTED, + /** Rejected during program/area coordinator feedback. */ + REJECTED_AT_COORDINATORS_FEEDBACK, + /** Rejected during examination board feedback. */ + REJECTED_AT_EXAMINATION_BOARD_FEEDBACK, CANCELLED, } diff --git a/Server/src/main/java/modulemanagement/ls1/enums/ProposalStatus.java b/Server/src/main/java/modulemanagement/ls1/enums/ProposalStatus.java index e4e7624f..6088b30c 100644 --- a/Server/src/main/java/modulemanagement/ls1/enums/ProposalStatus.java +++ b/Server/src/main/java/modulemanagement/ls1/enums/ProposalStatus.java @@ -4,25 +4,41 @@ public enum ProposalStatus { /** * Professor has not yet submitted for coordinator feedback (first submission). */ - PENDING_FIRST_SUBMISSION, + WAITING_FOR_COORDINATORS_SUBMISSION, /** * Submitted for coordinator feedback; waiting for program/area coordinators. */ - PENDING_COORDINATOR_FEEDBACK, + PENDING_COORDINATORS_FEEDBACK, /** * All coordinator responses are in; at least one gave non-approval feedback and * none approved yet. */ - COORDINATOR_FEEDBACK_GIVEN, + COORDINATORS_FEEDBACK_GIVEN, /** - * Coordinator feedback accepted; professor has not yet submitted for full - * feedback. + * Coordinator feedback accepted; professor has not yet submitted for + * examination + * board feedback. */ - PENDING_FULL_SUBMISSION, - /** Submitted for full feedback; waiting for QM, advisor, examination board. */ - PENDING_FULL_FEEDBACK, + WAITING_FOR_EXAMINATION_BOARD_SUBMISSION, + /** Submitted for examination board feedback; waiting for examination board. */ + PENDING_EXAMINATION_BOARD_FEEDBACK, + /** + * Examination board responses are in; at least one is not approval (e.g. + * FEEDBACK_GIVEN), or mixed. + */ + EXAMINATION_BOARD_FEEDBACK_GIVEN, + /** + * All examination board feedbacks approved; professor has not yet submitted for + * quality management feedback. + */ + WAITING_FOR_QUALITY_MANAGEMENT_SUBMISSION, + /** Submitted for quality management feedback. */ + PENDING_QUALITY_MANAGEMENT_FEEDBACK, ACCEPTED, REQUIRES_REVIEW, - REJECTED, + /** Rejected during program/area coordinator feedback. */ + REJECTED_AT_COORDINATORS_FEEDBACK, + /** Rejected during examination board feedback. */ + REJECTED_AT_EXAMINATION_BOARD_FEEDBACK, CANCELLED, } diff --git a/Server/src/main/java/modulemanagement/ls1/models/DegreeProgram.java b/Server/src/main/java/modulemanagement/ls1/models/DegreeProgram.java index 41adbdad..66518f38 100644 --- a/Server/src/main/java/modulemanagement/ls1/models/DegreeProgram.java +++ b/Server/src/main/java/modulemanagement/ls1/models/DegreeProgram.java @@ -29,4 +29,8 @@ public class DegreeProgram { @JoinTable(name = "degree_program_specialization_assignment", joinColumns = @JoinColumn(name = "degree_program_id"), inverseJoinColumns = @JoinColumn(name = "degree_program_specialization_id")) private List degreeProgramSpecializations = new ArrayList<>(); + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "examination_board_id") + private ExaminationBoard examinationBoard; + } diff --git a/Server/src/main/java/modulemanagement/ls1/models/ExaminationBoard.java b/Server/src/main/java/modulemanagement/ls1/models/ExaminationBoard.java new file mode 100644 index 00000000..f5027914 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/models/ExaminationBoard.java @@ -0,0 +1,31 @@ +package modulemanagement.ls1.models; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.HashSet; +import java.util.Set; + +@Data +@NoArgsConstructor +@Entity +@Table(name = "examination_board") +public class ExaminationBoard { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "examination_board_id") + private Long examinationBoardId; + + @Column(name = "name", nullable = false, length = 512) + private String name; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable(name = "examination_board_user", + joinColumns = @JoinColumn(name = "examination_board_id"), + inverseJoinColumns = @JoinColumn(name = "user_id")) + @JsonIgnore + private Set members = new HashSet<>(); +} diff --git a/Server/src/main/java/modulemanagement/ls1/models/Feedback.java b/Server/src/main/java/modulemanagement/ls1/models/Feedback.java index 9c94419a..481d0240 100644 --- a/Server/src/main/java/modulemanagement/ls1/models/Feedback.java +++ b/Server/src/main/java/modulemanagement/ls1/models/Feedback.java @@ -24,18 +24,32 @@ public class Feedback { @JoinColumn(name = "feedback_from") private User feedbackFrom; + /** + * User who must respond to this feedback (coordinator, examination board member, etc.). + */ + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "assigned_reviewer_user_id") + private User assignedReviewer; + @Column(name = "invalidated") private boolean invalidated; /** - * When set, this feedback is for whoever is currently responsible for this - * specialization (position-based). + * When set, identifies the specialization context (e.g. display for program / area coordinator). */ @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "degree_program_specialization_id") @JsonIgnore private DegreeProgramSpecialization degreeProgramSpecialization; + /** + * When set, identifies the examination board context for display (examination board feedback). + */ + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "examination_board_id") + @JsonIgnore + private ExaminationBoard examinationBoard; + @Column(name = "comment") private String Comment; diff --git a/Server/src/main/java/modulemanagement/ls1/models/ModuleVersion.java b/Server/src/main/java/modulemanagement/ls1/models/ModuleVersion.java index 07bb03bb..9f6e1586 100644 --- a/Server/src/main/java/modulemanagement/ls1/models/ModuleVersion.java +++ b/Server/src/main/java/modulemanagement/ls1/models/ModuleVersion.java @@ -32,6 +32,7 @@ public class ModuleVersion { private LocalDateTime creationDate; @Column(name = "status") + @Enumerated(EnumType.STRING) @NotNull private ModuleVersionStatus status; diff --git a/Server/src/main/java/modulemanagement/ls1/models/Proposal.java b/Server/src/main/java/modulemanagement/ls1/models/Proposal.java index 172a527c..21528bd1 100644 --- a/Server/src/main/java/modulemanagement/ls1/models/Proposal.java +++ b/Server/src/main/java/modulemanagement/ls1/models/Proposal.java @@ -29,6 +29,7 @@ public class Proposal { private LocalDateTime creationDate; @Column(name = "status") + @Enumerated(EnumType.STRING) @NotNull private ProposalStatus status; @@ -53,6 +54,7 @@ public void addNewModuleVersion() { newMv.setVersion(latestMv.getVersion() + 1); newMv.setCreationDate(LocalDateTime.now()); newMv.setStatus(latestMv.getStatus()); + newMv.setModuleId(latestMv.getModuleId()); newMv.setBulletPoints(latestMv.getBulletPoints()); newMv.setTitleEng(latestMv.getTitleEng()); newMv.setTitleDe(latestMv.getTitleDe()); @@ -71,11 +73,15 @@ public void addNewModuleVersion() { newMv.setHoursSelfStudy(latestMv.getHoursSelfStudy()); newMv.setHoursPresence(latestMv.getHoursPresence()); newMv.setExaminationAchievementsEng(latestMv.getExaminationAchievementsEng()); + newMv.setExaminationAchievementsPromptEng(latestMv.getExaminationAchievementsPromptEng()); newMv.setRepetitionEng(latestMv.getRepetitionEng()); newMv.setRecommendedPrerequisitesEng(latestMv.getRecommendedPrerequisitesEng()); newMv.setContentEng(latestMv.getContentEng()); + newMv.setContentPromptEng(latestMv.getContentPromptEng()); newMv.setLearningOutcomesEng(latestMv.getLearningOutcomesEng()); + newMv.setLearningOutcomesPromptEng(latestMv.getLearningOutcomesPromptEng()); newMv.setTeachingMethodsEng(latestMv.getTeachingMethodsEng()); + newMv.setTeachingMethodsPromptEng(latestMv.getTeachingMethodsPromptEng()); newMv.setMediaEng(latestMv.getMediaEng()); newMv.setLiteratureEng(latestMv.getLiteratureEng()); newMv.setResponsiblesEng(latestMv.getResponsiblesEng()); diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramRepository.java index b68d4146..bb1b7dde 100644 --- a/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramRepository.java +++ b/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramRepository.java @@ -3,7 +3,9 @@ import modulemanagement.ls1.models.DegreeProgram; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -17,15 +19,19 @@ public interface DegreeProgramRepository extends JpaRepository findByResponsibleUser_UserId(UUID userId); - @EntityGraph(attributePaths = { "responsibleUser" }) + @EntityGraph(attributePaths = { "responsibleUser", "examinationBoard" }) @Query("SELECT p FROM DegreeProgram p") List findAllWithResponsibleUser(); @EntityGraph(attributePaths = { "responsibleUser", "degreeProgramSpecializations", - "degreeProgramSpecializations.responsibleUser" }) + "degreeProgramSpecializations.responsibleUser", "examinationBoard" }) Optional findWithSpecializationsByDegreeProgramId(Long degreeProgramId); - @EntityGraph(attributePaths = { "degreeProgramSpecializations" }) + @EntityGraph(attributePaths = { "degreeProgramSpecializations", "examinationBoard" }) @Query("SELECT p FROM DegreeProgram p ORDER BY p.name") List findAllWithSpecializations(); + + @Modifying + @Query("UPDATE DegreeProgram p SET p.examinationBoard = null WHERE p.examinationBoard.examinationBoardId = :examinationBoardId") + void clearExaminationBoardIdByExaminationBoardId(@Param("examinationBoardId") Long examinationBoardId); } diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/ExaminationBoardRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/ExaminationBoardRepository.java new file mode 100644 index 00000000..f283d4b9 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/repositories/ExaminationBoardRepository.java @@ -0,0 +1,31 @@ +package modulemanagement.ls1.repositories; + +import modulemanagement.ls1.models.ExaminationBoard; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface ExaminationBoardRepository extends JpaRepository { + + @EntityGraph(attributePaths = { "members" }) + @Query("SELECT e FROM ExaminationBoard e WHERE e.examinationBoardId = :id") + Optional findByIdWithMembers(@Param("id") Long id); + + @EntityGraph(attributePaths = { "members" }) + @Query("SELECT e FROM ExaminationBoard e") + List findAllWithMembers(); + + @Query("SELECT COUNT(e) > 0 FROM ExaminationBoard e JOIN e.members m WHERE m.userId = :userId") + boolean existsByMemberUserId(@Param("userId") UUID userId); + + @EntityGraph(attributePaths = { "members" }) + @Query("SELECT DISTINCT e FROM ExaminationBoard e JOIN e.members m WHERE m.userId = :userId") + List findByMemberUserId(@Param("userId") UUID userId); +} diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java index 1422ee1c..33b2122d 100644 --- a/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java +++ b/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java @@ -1,21 +1,17 @@ package modulemanagement.ls1.repositories; import modulemanagement.ls1.enums.FeedbackStatus; -import modulemanagement.ls1.enums.UserRole; import modulemanagement.ls1.models.Feedback; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import java.util.Collection; import java.util.List; import java.util.UUID; @Repository public interface FeedbackRepository extends JpaRepository { - List findByRequiredRoleInAndStatusAndInvalidatedFalse(Collection requiredRoles, FeedbackStatus status); - - /** Feedbacks for specializations that this user is currently responsible for (position-based). */ - List findByDegreeProgramSpecialization_ResponsibleUser_UserIdAndStatusAndInvalidatedFalse(UUID userId, FeedbackStatus status); + /** Pending feedbacks explicitly assigned to this user (coordinators, examination board members, …). */ + List findByAssignedReviewer_UserIdAndStatusAndInvalidatedFalse(UUID userId, FeedbackStatus status); /** All feedbacks for a proposal that are not invalidated (for display on view/edit). */ List findByModuleVersion_Proposal_ProposalIdAndInvalidatedFalse(Long proposalId); diff --git a/Server/src/main/java/modulemanagement/ls1/services/AdminUserService.java b/Server/src/main/java/modulemanagement/ls1/services/AdminUserService.java index 4f0fb6b8..76a5b712 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/AdminUserService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/AdminUserService.java @@ -19,11 +19,11 @@ public class AdminUserService { private final UserRepository userRepository; - private final ResponsibleUserRoleService responsibleUserRoleService; + private final UserRolesSyncService userRolesSyncService; - public AdminUserService(UserRepository userRepository, ResponsibleUserRoleService responsibleUserRoleService) { + public AdminUserService(UserRepository userRepository, UserRolesSyncService userRolesSyncService) { this.userRepository = userRepository; - this.responsibleUserRoleService = responsibleUserRoleService; + this.userRolesSyncService = userRolesSyncService; } public PageResponseDTO getUsersPage(Pageable pageable, String search) { @@ -40,11 +40,14 @@ public UserDTO updateUserRole(UUID userId, UpdateUserRoleDTO dto) { List currentRoles = user.getRoles() != null ? user.getRoles() : List.of(); List newRoles = dto.getRoles() != null ? dto.getRoles() : List.of(); if (currentRoles.contains(UserRole.PROGRAM_COORDINATOR) && !newRoles.contains(UserRole.PROGRAM_COORDINATOR)) { - responsibleUserRoleService.unassignFromAllPrograms(userId); + userRolesSyncService.unassignFromAllPrograms(userId); } if (currentRoles.contains(UserRole.SPECIALIZATION_AREA_COORDINATOR) && !newRoles.contains(UserRole.SPECIALIZATION_AREA_COORDINATOR)) { - responsibleUserRoleService.unassignFromAllSpecializations(userId); + userRolesSyncService.unassignFromAllSpecializations(userId); + } + if (currentRoles.contains(UserRole.EXAMINATION_BOARD) && !newRoles.contains(UserRole.EXAMINATION_BOARD)) { + userRolesSyncService.unassignFromAllExaminationBoards(userId); } user.setRoles(dto.getRoles()); user = userRepository.save(user); diff --git a/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramService.java b/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramService.java index b2cff8fb..f09d2024 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramService.java @@ -5,6 +5,7 @@ import modulemanagement.ls1.models.DegreeProgram; import modulemanagement.ls1.repositories.DegreeProgramRepository; import modulemanagement.ls1.repositories.DegreeProgramSpecializationRepository; +import modulemanagement.ls1.repositories.ExaminationBoardRepository; import modulemanagement.ls1.repositories.UserRepository; import modulemanagement.ls1.shared.ResourceNotFoundException; import org.springframework.stereotype.Service; @@ -19,16 +20,19 @@ public class DegreeProgramService { private final DegreeProgramRepository degreeProgramRepository; private final DegreeProgramSpecializationRepository degreeProgramSpecializationRepository; private final UserRepository userRepository; - private final ResponsibleUserRoleService responsibleUserRoleService; + private final ExaminationBoardRepository examinationBoardRepository; + private final UserRolesSyncService userRolesSyncService; public DegreeProgramService(DegreeProgramRepository degreeProgramRepository, DegreeProgramSpecializationRepository degreeProgramSpecializationRepository, UserRepository userRepository, - ResponsibleUserRoleService responsibleUserRoleService) { + ExaminationBoardRepository examinationBoardRepository, + UserRolesSyncService userRolesSyncService) { this.degreeProgramRepository = degreeProgramRepository; this.degreeProgramSpecializationRepository = degreeProgramSpecializationRepository; this.userRepository = userRepository; - this.responsibleUserRoleService = responsibleUserRoleService; + this.examinationBoardRepository = examinationBoardRepository; + this.userRolesSyncService = userRolesSyncService; } public List getAllDegreePrograms() { @@ -54,7 +58,7 @@ public DegreeProgramDTO createDegreeProgram(CreateDegreeProgramDTO dto) { program.setName(dto.getName()); program.setResponsibleUser(userRepository.getReferenceById(dto.getResponsibleUserId())); program = degreeProgramRepository.save(program); - responsibleUserRoleService.ensureProgramCoordinatorRole(dto.getResponsibleUserId()); + userRolesSyncService.ensureProgramCoordinatorRole(dto.getResponsibleUserId()); program = degreeProgramRepository.findWithSpecializationsByDegreeProgramId(program.getDegreeProgramId()) .orElse(program); return DegreeProgramDTO.fromDegreeProgram(program); @@ -69,15 +73,21 @@ public DegreeProgramDTO updateDegreeProgram(Long id, UpdateDegreeProgramDTO dto) UUID previousUserId = program.getResponsibleUser() != null ? program.getResponsibleUser().getUserId() : null; program.setResponsibleUser(userRepository.getReferenceById(dto.getResponsibleUserId())); - program = degreeProgramRepository.save(program); - responsibleUserRoleService.ensureProgramCoordinatorRole(dto.getResponsibleUserId()); + userRolesSyncService.ensureProgramCoordinatorRole(dto.getResponsibleUserId()); if (previousUserId != null && !previousUserId.equals(dto.getResponsibleUserId())) - responsibleUserRoleService.removeProgramCoordinatorRoleIfNotResponsible(previousUserId); + userRolesSyncService.removeProgramCoordinatorRoleIfNotResponsible(previousUserId); + } + Long examinationBoardId = dto.getExaminationBoardId(); + if (examinationBoardId != null) { + program.setExaminationBoard(examinationBoardRepository.getReferenceById(examinationBoardId)); } else { - program = degreeProgramRepository.save(program); + program.setExaminationBoard(null); + } + program = degreeProgramRepository.save(program); program = degreeProgramRepository.findWithSpecializationsByDegreeProgramId(id).orElse(program); return DegreeProgramDTO.fromDegreeProgram(program); + } public void deleteDegreeProgram(Long id) { @@ -88,7 +98,7 @@ public void deleteDegreeProgram(Long id) { UUID responsibleUserId = program.getResponsibleUser() != null ? program.getResponsibleUser().getUserId() : null; degreeProgramRepository.deleteById(id); if (responsibleUserId != null) - responsibleUserRoleService.removeProgramCoordinatorRoleIfNotResponsible(responsibleUserId); + userRolesSyncService.removeProgramCoordinatorRoleIfNotResponsible(responsibleUserId); } public DegreeProgramDTO addSpecializationsToDegreeProgram(Long degreeProgramId, diff --git a/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramSpecializationService.java b/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramSpecializationService.java index 9fbd3b3a..979c378d 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramSpecializationService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramSpecializationService.java @@ -16,15 +16,15 @@ public class DegreeProgramSpecializationService { private final DegreeProgramSpecializationRepository degreeProgramSpecializationRepository; private final UserRepository userRepository; - private final ResponsibleUserRoleService responsibleUserRoleService; + private final UserRolesSyncService userRolesSyncService; public DegreeProgramSpecializationService( DegreeProgramSpecializationRepository degreeProgramSpecializationRepository, UserRepository userRepository, - ResponsibleUserRoleService responsibleUserRoleService) { + UserRolesSyncService userRolesSyncService) { this.degreeProgramSpecializationRepository = degreeProgramSpecializationRepository; this.userRepository = userRepository; - this.responsibleUserRoleService = responsibleUserRoleService; + this.userRolesSyncService = userRolesSyncService; } public List getAllDegreeProgramSpecializations() { @@ -38,7 +38,7 @@ public DegreeProgramSpecializationDTO createDegreeProgramSpecialization(CreateDe entity.setName(dto.getName()); entity.setResponsibleUser(userRepository.getReferenceById(dto.getResponsibleUserId())); entity = degreeProgramSpecializationRepository.save(entity); - responsibleUserRoleService.ensureSpecializationAreaCoordinatorRole(dto.getResponsibleUserId()); + userRolesSyncService.ensureSpecializationAreaCoordinatorRole(dto.getResponsibleUserId()); return DegreeProgramSpecializationDTO.fromEntity( degreeProgramSpecializationRepository .findByIdWithResponsibleUser(entity.getDegreeProgramSpecializationId()).orElse(entity)); @@ -54,9 +54,9 @@ public DegreeProgramSpecializationDTO updateDegreeProgramSpecialization(Long id, UUID previousUserId = entity.getResponsibleUser() != null ? entity.getResponsibleUser().getUserId() : null; entity.setResponsibleUser(userRepository.getReferenceById(dto.getResponsibleUserId())); entity = degreeProgramSpecializationRepository.save(entity); - responsibleUserRoleService.ensureSpecializationAreaCoordinatorRole(dto.getResponsibleUserId()); + userRolesSyncService.ensureSpecializationAreaCoordinatorRole(dto.getResponsibleUserId()); if (previousUserId != null && !previousUserId.equals(dto.getResponsibleUserId())) - responsibleUserRoleService.removeSpecializationAreaCoordinatorRoleIfNotResponsible(previousUserId); + userRolesSyncService.removeSpecializationAreaCoordinatorRoleIfNotResponsible(previousUserId); } else { entity = degreeProgramSpecializationRepository.save(entity); } @@ -72,6 +72,6 @@ public void deleteDegreeProgramSpecialization(Long id) { UUID responsibleUserId = entity.getResponsibleUser() != null ? entity.getResponsibleUser().getUserId() : null; degreeProgramSpecializationRepository.deleteById(id); if (responsibleUserId != null) - responsibleUserRoleService.removeSpecializationAreaCoordinatorRoleIfNotResponsible(responsibleUserId); + userRolesSyncService.removeSpecializationAreaCoordinatorRoleIfNotResponsible(responsibleUserId); } } diff --git a/Server/src/main/java/modulemanagement/ls1/services/ExaminationBoardService.java b/Server/src/main/java/modulemanagement/ls1/services/ExaminationBoardService.java new file mode 100644 index 00000000..5e9b564b --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/services/ExaminationBoardService.java @@ -0,0 +1,98 @@ +package modulemanagement.ls1.services; + +import modulemanagement.ls1.dtos.*; +import modulemanagement.ls1.models.ExaminationBoard; +import modulemanagement.ls1.models.User; +import modulemanagement.ls1.repositories.DegreeProgramRepository; +import modulemanagement.ls1.repositories.ExaminationBoardRepository; +import modulemanagement.ls1.repositories.UserRepository; +import modulemanagement.ls1.shared.ResourceNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +public class ExaminationBoardService { + + private final ExaminationBoardRepository examinationBoardRepository; + private final DegreeProgramRepository degreeProgramRepository; + private final UserRepository userRepository; + private final UserRolesSyncService userRolesSyncService; + + public ExaminationBoardService(ExaminationBoardRepository examinationBoardRepository, + DegreeProgramRepository degreeProgramRepository, + UserRepository userRepository, + UserRolesSyncService userRolesSyncService) { + this.examinationBoardRepository = examinationBoardRepository; + this.degreeProgramRepository = degreeProgramRepository; + this.userRepository = userRepository; + this.userRolesSyncService = userRolesSyncService; + } + + public List getAllExaminationBoards() { + return examinationBoardRepository.findAll().stream() + .map(ExaminationBoardSummaryDTO::fromEntity) + .collect(Collectors.toList()); + } + + public ExaminationBoardDTO getExaminationBoard(Long id) { + ExaminationBoard board = examinationBoardRepository.findByIdWithMembers(id) + .orElseThrow(() -> new ResourceNotFoundException("Examination board not found: " + id)); + return ExaminationBoardDTO.fromEntity(board); + } + + @Transactional + public ExaminationBoardDTO createExaminationBoard(CreateExaminationBoardDTO dto) { + ExaminationBoard board = new ExaminationBoard(); + board.setName(dto.getName().trim()); + board = examinationBoardRepository.save(board); + return ExaminationBoardDTO.fromEntity( + examinationBoardRepository.findByIdWithMembers(board.getExaminationBoardId()).orElse(board)); + } + + @Transactional + public ExaminationBoardDTO updateExaminationBoard(Long id, UpdateExaminationBoardDTO dto) { + ExaminationBoard board = examinationBoardRepository.findByIdWithMembers(id) + .orElseThrow(() -> new ResourceNotFoundException("Examination board not found: " + id)); + + board.setName(dto.getName().trim()); + + Set previousMemberIds = board.getMembers().stream().map(User::getUserId).collect(Collectors.toSet()); + Set newMemberIds = new HashSet<>(dto.getUserIds()); + + board.getMembers().clear(); + for (UUID userId : newMemberIds) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("User not found: " + userId)); + board.getMembers().add(user); + userRolesSyncService.ensureExaminationBoardRole(userId); + } + examinationBoardRepository.save(board); + + for (UUID oldId : previousMemberIds) { + if (!newMemberIds.contains(oldId)) { + userRolesSyncService.removeExaminationBoardRoleIfNotMember(oldId); + } + } + + return ExaminationBoardDTO.fromEntity( + examinationBoardRepository.findByIdWithMembers(id).orElse(board)); + } + + @Transactional + public void deleteExaminationBoard(Long id) { + ExaminationBoard board = examinationBoardRepository.findByIdWithMembers(id) + .orElseThrow(() -> new ResourceNotFoundException("Examination board not found: " + id)); + java.util.Set memberIds = board.getMembers().stream().map(User::getUserId).collect(Collectors.toSet()); + degreeProgramRepository.clearExaminationBoardIdByExaminationBoardId(id); + examinationBoardRepository.delete(board); + for (UUID uid : memberIds) { + userRolesSyncService.removeExaminationBoardRoleIfNotMember(uid); + } + } +} diff --git a/Server/src/main/java/modulemanagement/ls1/services/FeedbackService.java b/Server/src/main/java/modulemanagement/ls1/services/FeedbackService.java index c358d961..89aeddaf 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/FeedbackService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/FeedbackService.java @@ -15,11 +15,9 @@ import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; -import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Objects; -import java.util.stream.Stream; @Service @Validated @@ -75,15 +73,9 @@ public Feedback RejectFeedback(Long feedbackId, User user, @NotBlank String comm } public List getAllFeedbacksForUser(User user) { - List roleBased = user.getRoles() != null && !user.getRoles().isEmpty() - ? feedbackRepository.findByRequiredRoleInAndStatusAndInvalidatedFalse(user.getRoles(), FeedbackStatus.PENDING_FEEDBACK) - : Collections.emptyList(); - List bySpecialization = feedbackRepository - .findByDegreeProgramSpecialization_ResponsibleUser_UserIdAndStatusAndInvalidatedFalse(user.getUserId(), - FeedbackStatus.PENDING_FEEDBACK); - return Stream.of(roleBased.stream(), bySpecialization.stream()) - .flatMap(s -> s) - .distinct() + List assignedToUser = feedbackRepository.findByAssignedReviewer_UserIdAndStatusAndInvalidatedFalse( + user.getUserId(), FeedbackStatus.PENDING_FEEDBACK); + return assignedToUser.stream() .sorted(Comparator.comparing(Feedback::getFeedbackId)) .map(FeedbackListItemDto::fromFeedback) .toList(); @@ -92,10 +84,8 @@ public List getAllFeedbacksForUser(User user) { private boolean canUserRespondToFeedback(Feedback feedback, User user) { if (user == null) return false; - if (feedback.getDegreeProgramSpecialization() != null - && feedback.getDegreeProgramSpecialization().getResponsibleUser() != null) { - return Objects.equals(user.getUserId(), - feedback.getDegreeProgramSpecialization().getResponsibleUser().getUserId()); + if (feedback.getAssignedReviewer() != null) { + return Objects.equals(user.getUserId(), feedback.getAssignedReviewer().getUserId()); } return user.getRoles() != null && feedback.getRequiredRole() != null && user.getRoles().contains(feedback.getRequiredRole()); diff --git a/Server/src/main/java/modulemanagement/ls1/services/MailingService.java b/Server/src/main/java/modulemanagement/ls1/services/MailingService.java index abe31158..41e54592 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/MailingService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/MailingService.java @@ -92,9 +92,8 @@ public void sendProfessorFeedbackReceivedNotification(Feedback feedback) { private Set getFeedbackRecipients(Feedback feedback) { Set recipients = new LinkedHashSet<>(); - if (feedback.getDegreeProgramSpecialization() != null - && feedback.getDegreeProgramSpecialization().getResponsibleUser() != null) { - recipients.add(feedback.getDegreeProgramSpecialization().getResponsibleUser()); + if (feedback.getAssignedReviewer() != null) { + recipients.add(feedback.getAssignedReviewer()); return recipients; } @@ -108,9 +107,16 @@ private Set getFeedbackRecipients(Feedback feedback) { } private String buildReviewerRequestBody(Feedback feedback, String moduleTitle, User recipient) { - String reviewerScope = feedback.getDegreeProgramSpecialization() != null - ? "specialization: " + feedback.getDegreeProgramSpecialization().getName() - : "role: " + feedback.getRequiredRole(); + String reviewerScope; + if (feedback.getExaminationBoard() != null) { + reviewerScope = "examination board: " + feedback.getExaminationBoard().getName(); + } else if (feedback.getDegreeProgramSpecialization() != null) { + reviewerScope = "specialization: " + feedback.getDegreeProgramSpecialization().getName(); + } else if (feedback.getAssignedReviewer() != null) { + reviewerScope = "assigned reviewer"; + } else { + reviewerScope = "role: " + feedback.getRequiredRole(); + } return "Hello " + safeName(recipient) + ",\n\n" + "You have received a new review request for module proposal " + "\"" + moduleTitle + "\".\n" diff --git a/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java b/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java index 7d20f860..00d0d734 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java @@ -75,11 +75,14 @@ public ModuleVersionViewDTO updateModuleVersionFromRequest(UUID userId, Long mod } boolean step1Changed = ModuleVersionStepsChangeDetector.isStep1DataChanged(request, mv); + boolean postStep1Changed = ModuleVersionStepsChangeDetector.isPostStep1DataChanged(request, mv); applyUpdateRequest(mv, request); if (step1Changed) { invalidateActiveFeedbacksAndResetStatuses(mv); + } else if (postStep1Changed) { + invalidateExaminationBoardFeedbacksAndRewindExamPhase(mv); } mv = moduleVersionRepository.save(mv); @@ -100,8 +103,38 @@ private void invalidateActiveFeedbacksAndResetStatuses(ModuleVersion mv) { } feedbackRepository.saveAll(activeFeedbacks); - proposal.setStatus(ProposalStatus.PENDING_FIRST_SUBMISSION); - mv.setStatus(ModuleVersionStatus.PENDING_FIRST_SUBMISSION); + proposal.setStatus(ProposalStatus.WAITING_FOR_COORDINATORS_SUBMISSION); + mv.setStatus(ModuleVersionStatus.WAITING_FOR_COORDINATORS_SUBMISSION); + proposalRepository.save(proposal); + } + + /** + * When curriculum/content (steps after step 1) changes, prior examination board member + * feedback is no longer valid. Invalidate only those rows and return the workflow to + * examination-board submission; coordinator feedback is left unchanged. + */ + private void invalidateExaminationBoardFeedbacksAndRewindExamPhase(ModuleVersion latestDraft) { + Proposal proposal = latestDraft.getProposal(); + List activeFeedbacks = feedbackRepository + .findByModuleVersion_Proposal_ProposalIdAndInvalidatedFalse(proposal.getProposalId()); + if (activeFeedbacks == null || activeFeedbacks.isEmpty()) { + return; + } + List examinationBoardMemberFeedbacks = activeFeedbacks.stream() + .filter(f -> f.getExaminationBoard() != null + && f.getAssignedReviewer() != null + && f.getRequiredRole() == null + && f.getDegreeProgramSpecialization() == null) + .toList(); + if (examinationBoardMemberFeedbacks.isEmpty()) { + return; + } + for (Feedback f : examinationBoardMemberFeedbacks) { + f.setInvalidated(true); + } + feedbackRepository.saveAll(examinationBoardMemberFeedbacks); + proposal.setStatus(ProposalStatus.WAITING_FOR_EXAMINATION_BOARD_SUBMISSION); + latestDraft.setStatus(ModuleVersionStatus.WAITING_FOR_EXAMINATION_BOARD_SUBMISSION); proposalRepository.save(proposal); } @@ -191,21 +224,22 @@ public void updateStatus(Long moduleVersionId) { .filter(f -> f != null && !f.isInvalidated()) .toList(); List coordinatorFeedbacks = nonInvalidatedFeedbacks.stream() - .filter(f -> f.getDegreeProgramSpecialization() != null) + .filter(f -> f.getDegreeProgramSpecialization() != null + && f.getAssignedReviewer() != null + && f.getRequiredRole() == null + && f.getExaminationBoard() == null) .toList(); - List roleBased = nonInvalidatedFeedbacks.stream() - .filter(f -> f.getRequiredRole() != null) + List examBoardAssigned = nonInvalidatedFeedbacks.stream() + .filter(f -> f.getExaminationBoard() != null + && f.getAssignedReviewer() != null + && f.getRequiredRole() == null + && f.getDegreeProgramSpecialization() == null) .toList(); - - if (coordinatorFeedbacks.isEmpty() && roleBased.isEmpty()) { + if (coordinatorFeedbacks.isEmpty() && examBoardAssigned.isEmpty()) { return; } - if (!roleBased.isEmpty()) { - applyFullFeedbackRoleStatus(roleBased, mv, p); - } else { - applyCoordinatorFeedbackStatus(coordinatorFeedbacks, mv, p); - } + applyCoordinatorAndExaminationBoardStatus(coordinatorFeedbacks, examBoardAssigned, mv, p); syncWorkflowStatusToLatestModuleVersion(p, mv); proposalRepository.save(p); @@ -213,67 +247,68 @@ public void updateStatus(Long moduleVersionId) { } /** - * Coordinator phase only (no role-based feedbacks on this module version yet). - * 1) At least one rejected → REJECTED - * 2) Else at least one pending → PENDING_COORDINATOR_FEEDBACK - * 3) Else all accepted → PENDING_FULL_SUBMISSION - * 4) Else → COORDINATOR_FEEDBACK_GIVEN (e.g. all FEEDBACK_GIVEN, no approval/rejection) + * Derives proposal/module version status from coordinator and examination-board + * feedback rows on this module version. When both slices are + * non-empty, coordinator outcomes take precedence until every coordinator feedback + * is {@link FeedbackStatus#APPROVED}, then examination-board rules apply. */ - private void applyCoordinatorFeedbackStatus(List coordinatorFeedbacks, ModuleVersion mv, Proposal p) { - boolean hasRejected = coordinatorFeedbacks.stream() - .anyMatch(f -> f.getStatus() == FeedbackStatus.REJECTED); - if (hasRejected) { - mv.setStatus(ModuleVersionStatus.REJECTED); - p.setStatus(ProposalStatus.REJECTED); - return; - } - boolean hasPending = coordinatorFeedbacks.stream() - .anyMatch(f -> f.getStatus() == FeedbackStatus.PENDING_FEEDBACK); - if (hasPending) { - mv.setStatus(ModuleVersionStatus.PENDING_COORDINATOR_FEEDBACK); - p.setStatus(ProposalStatus.PENDING_COORDINATOR_FEEDBACK); + private void applyCoordinatorAndExaminationBoardStatus(List coordinatorFeedbacks, + List examBoardAssigned, ModuleVersion mv, Proposal p) { + boolean hasCoordinator = !coordinatorFeedbacks.isEmpty(); + boolean hasExam = !examBoardAssigned.isEmpty(); + + if (hasCoordinator && coordinatorFeedbacks.stream() + .anyMatch(f -> f.getStatus() == FeedbackStatus.REJECTED)) { + mv.setStatus(ModuleVersionStatus.REJECTED_AT_COORDINATORS_FEEDBACK); + p.setStatus(ProposalStatus.REJECTED_AT_COORDINATORS_FEEDBACK); return; } - boolean allApproved = coordinatorFeedbacks.stream() - .allMatch(f -> f.getStatus() == FeedbackStatus.APPROVED); - if (allApproved) { - mv.setStatus(ModuleVersionStatus.PENDING_FULL_SUBMISSION); - p.setStatus(ProposalStatus.PENDING_FULL_SUBMISSION); + if (hasExam && examBoardAssigned.stream() + .anyMatch(f -> f.getStatus() == FeedbackStatus.REJECTED)) { + mv.setStatus(ModuleVersionStatus.REJECTED_AT_EXAMINATION_BOARD_FEEDBACK); + p.setStatus(ProposalStatus.REJECTED_AT_EXAMINATION_BOARD_FEEDBACK); return; } - mv.setStatus(ModuleVersionStatus.COORDINATOR_FEEDBACK_GIVEN); - p.setStatus(ProposalStatus.COORDINATOR_FEEDBACK_GIVEN); - } - /** - * Second submission: QM / advisor / examination board feedbacks. - */ - private void applyFullFeedbackRoleStatus(List roleBased, ModuleVersion mv, Proposal p) { - boolean hasRejected = roleBased.stream() - .anyMatch(f -> f.getStatus() == FeedbackStatus.REJECTED); - if (hasRejected) { - mv.setStatus(ModuleVersionStatus.REJECTED); - p.setStatus(ProposalStatus.REJECTED); - return; + if (hasCoordinator) { + boolean coordinatorHasPending = coordinatorFeedbacks.stream() + .anyMatch(f -> f.getStatus() == FeedbackStatus.PENDING_FEEDBACK); + if (coordinatorHasPending) { + mv.setStatus(ModuleVersionStatus.PENDING_COORDINATORS_FEEDBACK); + p.setStatus(ProposalStatus.PENDING_COORDINATORS_FEEDBACK); + return; + } + boolean allCoordinatorsApproved = coordinatorFeedbacks.stream() + .allMatch(f -> f.getStatus() == FeedbackStatus.APPROVED); + if (!allCoordinatorsApproved) { + mv.setStatus(ModuleVersionStatus.COORDINATORS_FEEDBACK_GIVEN); + p.setStatus(ProposalStatus.COORDINATORS_FEEDBACK_GIVEN); + return; + } } - boolean hasPending = roleBased.stream() - .anyMatch(f -> f.getStatus() == FeedbackStatus.PENDING_FEEDBACK); - if (hasPending) { - mv.setStatus(ModuleVersionStatus.PENDING_FULL_FEEDBACK); - p.setStatus(ProposalStatus.PENDING_FULL_FEEDBACK); - return; - } - boolean allApproved = roleBased.stream() - .allMatch(f -> f.getStatus() == FeedbackStatus.APPROVED); - if (allApproved) { - mv.setStatus(ModuleVersionStatus.ACCEPTED); - p.setStatus(ProposalStatus.ACCEPTED); + if (hasExam) { + boolean examHasPending = examBoardAssigned.stream() + .anyMatch(f -> f.getStatus() == FeedbackStatus.PENDING_FEEDBACK); + if (examHasPending) { + mv.setStatus(ModuleVersionStatus.PENDING_EXAMINATION_BOARD_FEEDBACK); + p.setStatus(ProposalStatus.PENDING_EXAMINATION_BOARD_FEEDBACK); + return; + } + boolean allExamApproved = examBoardAssigned.stream() + .allMatch(f -> f.getStatus() == FeedbackStatus.APPROVED); + if (allExamApproved) { + mv.setStatus(ModuleVersionStatus.ACCEPTED); + p.setStatus(ProposalStatus.ACCEPTED); + return; + } + mv.setStatus(ModuleVersionStatus.EXAMINATION_BOARD_FEEDBACK_GIVEN); + p.setStatus(ProposalStatus.EXAMINATION_BOARD_FEEDBACK_GIVEN); return; } - mv.setStatus(ModuleVersionStatus.REQUIRES_REVIEW); - p.setStatus(ProposalStatus.REQUIRES_REVIEW); + mv.setStatus(ModuleVersionStatus.WAITING_FOR_EXAMINATION_BOARD_SUBMISSION); + p.setStatus(ProposalStatus.WAITING_FOR_EXAMINATION_BOARD_SUBMISSION); } /** diff --git a/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java b/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java index 3386b702..a6d1fe11 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java @@ -4,12 +4,14 @@ import modulemanagement.ls1.enums.*; import modulemanagement.ls1.models.DegreeProgram; import modulemanagement.ls1.models.DegreeProgramSpecialization; +import modulemanagement.ls1.models.ExaminationBoard; import modulemanagement.ls1.models.Feedback; import modulemanagement.ls1.models.ModuleVersion; import modulemanagement.ls1.models.ModuleVersionDegreeProgramAssignment; import modulemanagement.ls1.models.Proposal; import modulemanagement.ls1.models.User; import modulemanagement.ls1.repositories.DegreeProgramRepository; +import modulemanagement.ls1.repositories.ExaminationBoardRepository; import modulemanagement.ls1.repositories.FeedbackRepository; import modulemanagement.ls1.repositories.ModuleVersionRepository; import modulemanagement.ls1.repositories.ProposalRepository; @@ -23,10 +25,13 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; +import java.util.stream.Stream; @Service @Validated @@ -36,15 +41,18 @@ public class ProposalService { private final ModuleVersionRepository moduleVersionRepository; private final FeedbackRepository feedbackRepository; private final DegreeProgramRepository degreeProgramRepository; + private final ExaminationBoardRepository examinationBoardRepository; private final MailingService mailingService; public ProposalService(ProposalRepository proposalRepository, ModuleVersionRepository moduleVersionRepository, FeedbackRepository feedbackRepository, DegreeProgramRepository degreeProgramRepository, + ExaminationBoardRepository examinationBoardRepository, MailingService mailingService) { this.proposalRepository = proposalRepository; this.moduleVersionRepository = moduleVersionRepository; this.feedbackRepository = feedbackRepository; this.degreeProgramRepository = degreeProgramRepository; + this.examinationBoardRepository = examinationBoardRepository; this.mailingService = mailingService; } @@ -53,7 +61,7 @@ public ProposalViewDTO createProposalFromRequest(User user, ProposalRequestDTO r Proposal p = new Proposal(); p.setCreatedBy(user); p.setCreationDate(LocalDateTime.now()); - p.setStatus(ProposalStatus.PENDING_FIRST_SUBMISSION); + p.setStatus(ProposalStatus.WAITING_FOR_COORDINATORS_SUBMISSION); p = proposalRepository.save(p); ModuleVersion mv = new ModuleVersion(); @@ -61,7 +69,7 @@ public ProposalViewDTO createProposalFromRequest(User user, ProposalRequestDTO r mv.setModuleId(null); mv.setCreationDate(LocalDateTime.now()); mv.setProposal(p); - mv.setStatus(ModuleVersionStatus.PENDING_FIRST_SUBMISSION); + mv.setStatus(ModuleVersionStatus.WAITING_FOR_COORDINATORS_SUBMISSION); mv.setBulletPoints(request.getBulletPoints()); mv.setTitleEng(request.getTitleEng()); mv.setTitleDe(request.getTitleDe()); @@ -168,8 +176,9 @@ public ProposalViewDTO requestCoordinatorsFeedback(Long proposalId, UUID userId) ModuleVersion mv = proposal.getLatestModuleVersionWithContent(); - if (!mv.getStatus().equals(ModuleVersionStatus.PENDING_FIRST_SUBMISSION)) { - throw new IllegalStateException("Proposal is not pending first submission. It is " + mv.getStatus() + "."); + if (!mv.getStatus().equals(ModuleVersionStatus.WAITING_FOR_COORDINATORS_SUBMISSION)) { + throw new IllegalStateException( + "Proposal is not waiting for coordinator submission. It is " + mv.getStatus() + "."); } if (!mv.isFirstStepComplete()) { @@ -181,17 +190,23 @@ public ProposalViewDTO requestCoordinatorsFeedback(Long proposalId, UUID userId) for (ModuleVersionDegreeProgramAssignment assignment : mv.getDegreeProgramAssignments()) { var spec = assignment.getDegreeProgramSpecialization(); + if (spec.getResponsibleUser() == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Specialization \"" + spec.getName() + "\" has no responsible user. " + + "Assign a program or area coordinator before submitting for feedback."); + } Feedback feedback = new Feedback(); feedback.setStatus(FeedbackStatus.PENDING_FEEDBACK); feedback.setCreatedAt(LocalDateTime.now()); feedback.setDegreeProgramSpecialization(spec); + feedback.setAssignedReviewer(spec.getResponsibleUser()); feedback.setRequiredRole(null); feedback.setModuleVersion(mv); requiredFeedbacks.add(feedbackRepository.save(feedback)); } - mv.setStatus(ModuleVersionStatus.PENDING_COORDINATOR_FEEDBACK); - proposal.setStatus(ProposalStatus.PENDING_COORDINATOR_FEEDBACK); + mv.setStatus(ModuleVersionStatus.PENDING_COORDINATORS_FEEDBACK); + proposal.setStatus(ProposalStatus.PENDING_COORDINATORS_FEEDBACK); moduleVersionRepository.save(mv); // to keep the version with requested feedback immutable @@ -205,16 +220,14 @@ public ProposalViewDTO requestCoordinatorsFeedback(Long proposalId, UUID userId) } /** - * Roles that receive feedback in the second submission (after coordinator - * feedback is accepted). + * Request feedback from examination board members. Only after all content steps + * are complete, + * proposal is at examination-board submission, and every coordinator feedback + * for current + * assignments is approved. */ - private static final Set FULL_FEEDBACK_ROLES = Set.of( - UserRole.QUALITY_MANAGEMENT, - UserRole.ACADEMIC_PROGRAM_ADVISOR, - UserRole.EXAMINATION_BOARD); - @Transactional - public ProposalViewDTO requestFullFeedback(Long proposalId, UUID userId) { + public ProposalViewDTO requestExaminationBoardFeedback(Long proposalId, UUID userId) { Proposal proposal = proposalRepository.findById(proposalId) .orElseThrow(() -> new IllegalArgumentException("No proposal with id " + proposalId + " found")); if (!proposal.getCreatedBy().getUserId().equals(userId)) { @@ -225,20 +238,20 @@ public ProposalViewDTO requestFullFeedback(Long proposalId, UUID userId) { } ModuleVersion mv = proposal.getLatestModuleVersionWithContent(); - if (!mv.getStatus().equals(ModuleVersionStatus.PENDING_FULL_SUBMISSION)) { + if (!mv.getStatus().equals(ModuleVersionStatus.WAITING_FOR_EXAMINATION_BOARD_SUBMISSION)) { + throw new IllegalStateException( + "Proposal is not waiting for examination board submission. It is " + mv.getStatus() + "."); + } + if (!proposal.getStatus().equals(ProposalStatus.WAITING_FOR_EXAMINATION_BOARD_SUBMISSION)) { throw new IllegalStateException( - "Proposal must be in PENDING_FULL_SUBMISSION (coordinator feedback accepted). It is " - + mv.getStatus() + "."); + "Proposal workflow is not at examination board submission. It is " + proposal.getStatus() + "."); } if (!mv.isCompleted()) { - throw new IllegalStateException("All steps must be completed before submitting for full feedback."); + throw new IllegalStateException( + "All steps must be completed before submitting for examination board feedback."); } - List requiredFeedbacks = mv.getRequiredFeedbacks() != null ? mv.getRequiredFeedbacks() - : new ArrayList<>(); - List coordinatorFeedbacks = requiredFeedbacks.stream() - .filter(f -> f.getDegreeProgramSpecialization() != null && !f.isInvalidated()) - .toList(); + List coordinatorFeedbacks = currentCoordinatorFeedbacks(proposal, mv); if (coordinatorFeedbacks.isEmpty()) { throw new IllegalStateException( "No coordinator feedbacks for current assignments. Submit for coordinator feedback first."); @@ -247,32 +260,58 @@ public ProposalViewDTO requestFullFeedback(Long proposalId, UUID userId) { .allMatch(f -> f.getStatus() == FeedbackStatus.APPROVED); if (!allCoordinatorAccepted) { throw new IllegalStateException( - "All feedback from program and area coordinators (for current assignments) must be accepted before submitting for full feedback."); + "All coordinator feedbacks must be approved before submitting for examination board feedback."); } - // Create new feedbacks (one per role); do not reuse old ones. - List newFullFeedbackRequests = new ArrayList<>(); - for (UserRole role : FULL_FEEDBACK_ROLES) { - Feedback feedback = new Feedback(); - feedback.setStatus(FeedbackStatus.PENDING_FEEDBACK); - feedback.setCreatedAt(LocalDateTime.now()); - feedback.setRequiredRole(role); - feedback.setDegreeProgramSpecialization(null); - feedback.setModuleVersion(mv); - Feedback saved = feedbackRepository.save(feedback); - requiredFeedbacks.add(saved); - newFullFeedbackRequests.add(saved); + LinkedHashSet boardIds = new LinkedHashSet<>(); + for (ModuleVersionDegreeProgramAssignment assignment : mv.getDegreeProgramAssignments()) { + DegreeProgram program = degreeProgramRepository + .findById(assignment.getDegreeProgram().getDegreeProgramId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Degree program not found: " + assignment.getDegreeProgram().getDegreeProgramId())); + if (program.getExaminationBoard() == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Degree program \"" + program.getName() + "\" has no examination board assigned. " + + "An administrator must link an examination board before you can continue."); + } + boardIds.add(program.getExaminationBoard().getExaminationBoardId()); } - mv.setStatus(ModuleVersionStatus.PENDING_FULL_FEEDBACK); - proposal.setStatus(ProposalStatus.PENDING_FULL_FEEDBACK); + List newExaminationBoardFeedbacks = new ArrayList<>(); + Set seenMemberPerBoard = new java.util.HashSet<>(); + for (Long boardId : boardIds) { + ExaminationBoard board = examinationBoardRepository.findByIdWithMembers(boardId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Examination board not found: " + boardId)); + if (board.getMembers() == null || board.getMembers().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Examination board \"" + board.getName() + "\" has no members. Add members in administration."); + } + for (User member : board.getMembers()) { + String dedupeKey = boardId + ":" + member.getUserId(); + if (!seenMemberPerBoard.add(dedupeKey)) { + continue; + } + Feedback feedback = new Feedback(); + feedback.setStatus(FeedbackStatus.PENDING_FEEDBACK); + feedback.setCreatedAt(LocalDateTime.now()); + feedback.setRequiredRole(null); + feedback.setAssignedReviewer(member); + feedback.setExaminationBoard(board); + feedback.setDegreeProgramSpecialization(null); + feedback.setModuleVersion(mv); + newExaminationBoardFeedbacks.add(feedbackRepository.save(feedback)); + } + } + + mv.setStatus(ModuleVersionStatus.PENDING_EXAMINATION_BOARD_FEEDBACK); + proposal.setStatus(ProposalStatus.PENDING_EXAMINATION_BOARD_FEEDBACK); moduleVersionRepository.save(mv); - // to keep the version with requested feedback immutable proposal.addNewModuleVersion(); proposalRepository.save(proposal); mailingService.sendReviewerRequestNotification( - newFullFeedbackRequests, + newExaminationBoardFeedbacks, mv.getTitleEng() != null ? mv.getTitleEng() : "Untitled module"); return ProposalViewDTO.from(proposal); @@ -284,11 +323,61 @@ public void deleteProposalById(long proposalId, UUID userId) { if (!p.getCreatedBy().getUserId().equals(userId)) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized access"); } - if (!p.getStatus().equals(ProposalStatus.PENDING_FIRST_SUBMISSION)) { + if (!p.getStatus().equals(ProposalStatus.WAITING_FOR_COORDINATORS_SUBMISSION)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "You can only delete a proposal that is not already submitted. This module proposal is " + p.getStatus() + "."); } proposalRepository.delete(p); } + + /** + * Feedback rows are stored on the module version that was current when the + * request was made; + * after {@code addNewModuleVersion()}, the latest version may have an empty + * list while prior + * versions still hold active coordinator / examination-board rows. Aggregate + * for validation. + */ + private static List nonInvalidatedFeedbacksForProposal(Proposal proposal) { + if (proposal.getModuleVersions() == null || proposal.getModuleVersions().isEmpty()) { + return List.of(); + } + return proposal.getModuleVersions().stream() + .flatMap(m -> m.getRequiredFeedbacks() != null + ? m.getRequiredFeedbacks().stream() + : Stream.empty()) + .filter(f -> f != null && !f.isInvalidated()) + .toList(); + } + + private static Set currentAssignmentSpecializationIds(ModuleVersion latest) { + if (latest.getDegreeProgramAssignments() == null) { + return Set.of(); + } + return latest.getDegreeProgramAssignments().stream() + .map(a -> a.getDegreeProgramSpecialization() != null + ? a.getDegreeProgramSpecialization().getDegreeProgramSpecializationId() + : null) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + /** + * Coordinator slice for specializations currently assigned on {@code latest}, + * any version. + */ + private static List currentCoordinatorFeedbacks(Proposal proposal, ModuleVersion latest) { + Set specIds = currentAssignmentSpecializationIds(latest); + if (specIds.isEmpty()) { + return List.of(); + } + return nonInvalidatedFeedbacksForProposal(proposal).stream() + .filter(f -> f.getDegreeProgramSpecialization() != null + && specIds.contains(f.getDegreeProgramSpecialization().getDegreeProgramSpecializationId()) + && f.getAssignedReviewer() != null + && f.getRequiredRole() == null + && f.getExaminationBoard() == null) + .toList(); + } } diff --git a/Server/src/main/java/modulemanagement/ls1/services/UserRolesSyncService.java b/Server/src/main/java/modulemanagement/ls1/services/UserRolesSyncService.java new file mode 100644 index 00000000..44e8e4d0 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/services/UserRolesSyncService.java @@ -0,0 +1,145 @@ +package modulemanagement.ls1.services; + +import modulemanagement.ls1.enums.UserRole; +import modulemanagement.ls1.models.DegreeProgram; +import modulemanagement.ls1.models.DegreeProgramSpecialization; +import modulemanagement.ls1.models.ExaminationBoard; +import modulemanagement.ls1.models.User; +import modulemanagement.ls1.repositories.DegreeProgramRepository; +import modulemanagement.ls1.repositories.DegreeProgramSpecializationRepository; +import modulemanagement.ls1.repositories.ExaminationBoardRepository; +import modulemanagement.ls1.repositories.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Keeps PROGRAM_COORDINATOR and SPECIALIZATION_AREA_COORDINATOR roles in sync: + * - Program coordinators (responsible for a degree program) have + * PROGRAM_COORDINATOR. + * - Specialization area coordinators have SPECIALIZATION_AREA_COORDINATOR. + */ +@Service +public class UserRolesSyncService { + + private final UserRepository userRepository; + private final DegreeProgramRepository degreeProgramRepository; + private final DegreeProgramSpecializationRepository degreeProgramSpecializationRepository; + private final ExaminationBoardRepository examinationBoardRepository; + + public UserRolesSyncService(UserRepository userRepository, + DegreeProgramRepository degreeProgramRepository, + DegreeProgramSpecializationRepository degreeProgramSpecializationRepository, + ExaminationBoardRepository examinationBoardRepository) { + this.userRepository = userRepository; + this.degreeProgramRepository = degreeProgramRepository; + this.degreeProgramSpecializationRepository = degreeProgramSpecializationRepository; + this.examinationBoardRepository = examinationBoardRepository; + } + + @Transactional + public void ensureProgramCoordinatorRole(UUID userId) { + User user = userRepository.findById(userId).orElse(null); + if (user == null) + return; + if (user.getRoles() != null && user.getRoles().contains(UserRole.PROGRAM_COORDINATOR)) + return; + if (user.getRoles() == null) + user.setRoles(new ArrayList<>()); + user.getRoles().add(UserRole.PROGRAM_COORDINATOR); + userRepository.save(user); + } + + @Transactional + public void removeProgramCoordinatorRoleIfNotResponsible(UUID userId) { + if (degreeProgramRepository.existsByResponsibleUser_UserId(userId)) + return; + User user = userRepository.findById(userId).orElse(null); + if (user == null || user.getRoles() == null) + return; + if (user.getRoles().remove(UserRole.PROGRAM_COORDINATOR)) + userRepository.save(user); + } + + @Transactional + public void ensureSpecializationAreaCoordinatorRole(UUID userId) { + User user = userRepository.findById(userId).orElse(null); + if (user == null) + return; + if (user.getRoles() != null && user.getRoles().contains(UserRole.SPECIALIZATION_AREA_COORDINATOR)) + return; + if (user.getRoles() == null) + user.setRoles(new ArrayList<>()); + user.getRoles().add(UserRole.SPECIALIZATION_AREA_COORDINATOR); + userRepository.save(user); + } + + @Transactional + public void removeSpecializationAreaCoordinatorRoleIfNotResponsible(UUID userId) { + if (degreeProgramSpecializationRepository.existsByResponsibleUser_UserId(userId)) + return; + User user = userRepository.findById(userId).orElse(null); + if (user == null || user.getRoles() == null) + return; + if (user.getRoles().remove(UserRole.SPECIALIZATION_AREA_COORDINATOR)) + userRepository.save(user); + } + + @Transactional + public void unassignFromAllPrograms(UUID userId) { + List programs = degreeProgramRepository.findByResponsibleUser_UserId(userId); + for (DegreeProgram p : programs) { + p.setResponsibleUser(null); + } + if (!programs.isEmpty()) + degreeProgramRepository.saveAll(programs); + } + + @Transactional + public void unassignFromAllSpecializations(UUID userId) { + List specs = degreeProgramSpecializationRepository + .findByResponsibleUser_UserId(userId); + for (DegreeProgramSpecialization s : specs) { + s.setResponsibleUser(null); + } + if (!specs.isEmpty()) + degreeProgramSpecializationRepository.saveAll(specs); + } + + @Transactional + public void ensureExaminationBoardRole(UUID userId) { + User user = userRepository.findById(userId).orElse(null); + if (user == null) + return; + if (user.getRoles() != null && user.getRoles().contains(UserRole.EXAMINATION_BOARD)) + return; + if (user.getRoles() == null) + user.setRoles(new ArrayList<>()); + user.getRoles().add(UserRole.EXAMINATION_BOARD); + userRepository.save(user); + } + + @Transactional + public void removeExaminationBoardRoleIfNotMember(UUID userId) { + if (examinationBoardRepository.existsByMemberUserId(userId)) + return; + User user = userRepository.findById(userId).orElse(null); + if (user == null || user.getRoles() == null) + return; + if (user.getRoles().remove(UserRole.EXAMINATION_BOARD)) + userRepository.save(user); + } + + @Transactional + public void unassignFromAllExaminationBoards(UUID userId) { + List boards = examinationBoardRepository.findByMemberUserId(userId); + for (ExaminationBoard b : boards) { + b.getMembers().removeIf(u -> u.getUserId().equals(userId)); + } + if (!boards.isEmpty()) + examinationBoardRepository.saveAll(boards); + } +} diff --git a/Server/src/main/java/modulemanagement/ls1/shared/ModuleVersionStepsChangeDetector.java b/Server/src/main/java/modulemanagement/ls1/shared/ModuleVersionStepsChangeDetector.java index 13d5840c..9009b1e7 100644 --- a/Server/src/main/java/modulemanagement/ls1/shared/ModuleVersionStepsChangeDetector.java +++ b/Server/src/main/java/modulemanagement/ls1/shared/ModuleVersionStepsChangeDetector.java @@ -44,6 +44,80 @@ public static boolean isStep1DataChanged(ModuleVersionUpdateRequestDTO request, return !requestAssignments.equals(mvAssignments); } + /** + * True when any module field outside step 1 (basic + assignment set) differs from the + * persisted version. Used to invalidate examination board feedback when curriculum/content + * changes without altering step 1. + */ + public static boolean isPostStep1DataChanged(ModuleVersionUpdateRequestDTO request, ModuleVersion mv) { + if (!Objects.equals(normalize(request.getBulletPoints()), normalize(mv.getBulletPoints()))) { + return true; + } + if (!Objects.equals(normalize(request.getLevelEng()), normalize(mv.getLevelEng()))) { + return true; + } + if (!Objects.equals(normalize(request.getDuration()), normalize(mv.getDuration()))) { + return true; + } + if (!Objects.equals(request.getHoursTotal(), mv.getHoursTotal())) { + return true; + } + if (!Objects.equals(request.getHoursSelfStudy(), mv.getHoursSelfStudy())) { + return true; + } + if (!Objects.equals(request.getHoursPresence(), mv.getHoursPresence())) { + return true; + } + if (!Objects.equals(normalize(request.getExaminationAchievementsEng()), + normalize(mv.getExaminationAchievementsEng()))) { + return true; + } + if (!Objects.equals(normalize(request.getExaminationAchievementsPromptEng()), + normalize(mv.getExaminationAchievementsPromptEng()))) { + return true; + } + if (!Objects.equals(normalize(request.getRepetitionEng()), normalize(mv.getRepetitionEng()))) { + return true; + } + if (!Objects.equals(normalize(request.getRecommendedPrerequisitesEng()), + normalize(mv.getRecommendedPrerequisitesEng()))) { + return true; + } + if (!Objects.equals(normalize(request.getContentEng()), normalize(mv.getContentEng()))) { + return true; + } + if (!Objects.equals(normalize(request.getContentPromptEng()), normalize(mv.getContentPromptEng()))) { + return true; + } + if (!Objects.equals(normalize(request.getLearningOutcomesEng()), normalize(mv.getLearningOutcomesEng()))) { + return true; + } + if (!Objects.equals(normalize(request.getLearningOutcomesPromptEng()), + normalize(mv.getLearningOutcomesPromptEng()))) { + return true; + } + if (!Objects.equals(normalize(request.getTeachingMethodsEng()), normalize(mv.getTeachingMethodsEng()))) { + return true; + } + if (!Objects.equals(normalize(request.getTeachingMethodsPromptEng()), + normalize(mv.getTeachingMethodsPromptEng()))) { + return true; + } + if (!Objects.equals(normalize(request.getMediaEng()), normalize(mv.getMediaEng()))) { + return true; + } + if (!Objects.equals(normalize(request.getLiteratureEng()), normalize(mv.getLiteratureEng()))) { + return true; + } + if (!Objects.equals(normalize(request.getResponsiblesEng()), normalize(mv.getResponsiblesEng()))) { + return true; + } + if (!Objects.equals(normalize(request.getLvSwsLecturerEng()), normalize(mv.getLvSwsLecturerEng()))) { + return true; + } + return false; + } + private static String normalize(String s) { if (s == null) return null; diff --git a/Server/src/main/resources/db/changelog/changes/0015_examination_board.yaml b/Server/src/main/resources/db/changelog/changes/0015_examination_board.yaml new file mode 100644 index 00000000..608b5fe8 --- /dev/null +++ b/Server/src/main/resources/db/changelog/changes/0015_examination_board.yaml @@ -0,0 +1,62 @@ +databaseChangeLog: + - changeSet: + id: 0015a_examination_board_tables + author: module-management + changes: + - createTable: + tableName: examination_board + columns: + - column: + name: examination_board_id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + - column: + name: name + type: VARCHAR(512) + constraints: + nullable: false + - createTable: + tableName: examination_board_user + columns: + - column: + name: examination_board_id + type: BIGINT + constraints: + nullable: false + - column: + name: user_id + type: UUID + constraints: + nullable: false + - addPrimaryKey: + tableName: examination_board_user + columnNames: examination_board_id, user_id + constraintName: pk_examination_board_user + - addForeignKeyConstraint: + baseTableName: examination_board_user + baseColumnNames: examination_board_id + referencedTableName: examination_board + referencedColumnNames: examination_board_id + constraintName: fk_ebu_examination_board + - addForeignKeyConstraint: + baseTableName: examination_board_user + baseColumnNames: user_id + referencedTableName: app_user + referencedColumnNames: user_id + constraintName: fk_ebu_user + - addColumn: + tableName: degree_program + columns: + - column: + name: examination_board_id + type: BIGINT + constraints: + nullable: true + - addForeignKeyConstraint: + baseTableName: degree_program + baseColumnNames: examination_board_id + referencedTableName: examination_board + referencedColumnNames: examination_board_id + constraintName: fk_degree_program_examination_board diff --git a/Server/src/main/resources/db/changelog/changes/0016_feedback_assigned_reviewer.yaml b/Server/src/main/resources/db/changelog/changes/0016_feedback_assigned_reviewer.yaml new file mode 100644 index 00000000..af8365e6 --- /dev/null +++ b/Server/src/main/resources/db/changelog/changes/0016_feedback_assigned_reviewer.yaml @@ -0,0 +1,30 @@ +databaseChangeLog: + - changeSet: + id: 0016_feedback_assigned_reviewer + author: module-management + changes: + - addColumn: + tableName: feedback + columns: + - column: + name: assigned_reviewer_user_id + type: UUID + constraints: + nullable: true + foreignKeyName: feedback_assigned_reviewer_user_fk + references: app_user(user_id) + + - changeSet: + id: 0016_feedback_examination_board_ref + author: module-management + changes: + - addColumn: + tableName: feedback + columns: + - column: + name: examination_board_id + type: BIGINT + constraints: + nullable: true + foreignKeyName: feedback_examination_board_fk + references: examination_board(examination_board_id) \ No newline at end of file diff --git a/Server/src/main/resources/db/changelog/master.yaml b/Server/src/main/resources/db/changelog/master.yaml index 50f77c43..ee7d4285 100644 --- a/Server/src/main/resources/db/changelog/master.yaml +++ b/Server/src/main/resources/db/changelog/master.yaml @@ -41,3 +41,9 @@ databaseChangeLog: - include: relativeToChangelogFile: true file: changes/0014_feedback_additional_fields.yaml + - include: + relativeToChangelogFile: true + file: changes/0015_examination_board.yaml + - include: + relativeToChangelogFile: true + file: changes/0016_feedback_assigned_reviewer.yaml diff --git a/docker/docker-compose.dev.yaml b/docker/docker-compose.dev.yaml index 335d7eb8..90aae32b 100644 --- a/docker/docker-compose.dev.yaml +++ b/docker/docker-compose.dev.yaml @@ -15,7 +15,11 @@ services: restart: unless-stopped keycloak: - image: quay.io/keycloak/keycloak:26.5.3 + build: + context: .. + dockerfile: docker/keycloak/Dockerfile + image: module-management-keycloak:dev + pull_policy: build container_name: module-management-keycloak environment: KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin} diff --git a/docker/docker-compose.staging.yaml b/docker/docker-compose.staging.yaml index 3f6b0255..ce261a2c 100644 --- a/docker/docker-compose.staging.yaml +++ b/docker/docker-compose.staging.yaml @@ -33,7 +33,7 @@ services: restart: unless-stopped keycloak: - image: quay.io/keycloak/keycloak:26.5.3 + image: quay.io/keycloak/keycloak:26.5 container_name: module-management-keycloak environment: - KC_BOOTSTRAP_ADMIN_USERNAME=${KEYCLOAK_ADMIN_USERNAME} diff --git a/docker/keycloak/Dockerfile b/docker/keycloak/Dockerfile new file mode 100644 index 00000000..6a6f60af --- /dev/null +++ b/docker/keycloak/Dockerfile @@ -0,0 +1,17 @@ +# Keycloak image with Module-Management passkey realm extension (WebAuthn /passkey/*) +FROM maven:3.9-eclipse-temurin-21 AS extension-build +WORKDIR /build/extension +COPY keycloak-extension-passkey/ /build/extension/ +# Resolve built JAR to a fixed path (artifactId/version may change; mvn failure fails the stage). +RUN --mount=type=cache,target=/root/.m2 \ + mvn -ntp -DskipTests package \ + && cp target/custom-endpoint-*.jar /passkey-provider.jar + +FROM quay.io/keycloak/keycloak:26.5 AS kc-with-provider +COPY --from=extension-build --chown=keycloak:keycloak \ + /passkey-provider.jar \ + /opt/keycloak/providers/module-management-passkey.jar +RUN /opt/keycloak/bin/kc.sh build + +FROM quay.io/keycloak/keycloak:26.5 +COPY --from=kc-with-provider /opt/keycloak/ /opt/keycloak/ diff --git a/keycloak-extension-passkey/pom.xml b/keycloak-extension-passkey/pom.xml new file mode 100644 index 00000000..b8ba93a1 --- /dev/null +++ b/keycloak-extension-passkey/pom.xml @@ -0,0 +1,76 @@ + + 4.0.0 + + com.example.keycloak + custom-endpoint + 1.0-SNAPSHOT + jar + + custom-endpoint + http://maven.apache.org + + + UTF-8 + 26.1.3 + + + + + org.keycloak + keycloak-server-spi + ${keycloak.version} + + + org.keycloak + keycloak-services + ${keycloak.version} + + + org.keycloak + keycloak-server-spi-private + ${keycloak.version} + provided + + + org.jboss.resteasy + resteasy-core-spi + 6.2.11.Final + + + org.projectlombok + lombok + 1.18.38 + provided + + + com.fasterxml.jackson.core + jackson-core + 2.18.3 + + + com.fasterxml.jackson.core + jackson-databind + 2.13.4.2 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 16 + 16 + + + org.projectlombok + lombok + 1.18.38 + + + + + + + diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyRequest.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyRequest.java new file mode 100644 index 00000000..603edadb --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyRequest.java @@ -0,0 +1,35 @@ +package com.example.keycloak; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PasskeyRequest { + + @JsonProperty("credentialId") + private String credentialId; + + @JsonProperty("rawId") + private String rawId; + + @JsonProperty("attestationObject") + private String attestationObject; + + @JsonProperty("clientDataJSON") + private String clientDataJSON; + + @JsonProperty("authenticatorData") + private String authenticatorData; + + @JsonProperty("signature") + private String signature; + + @JsonProperty("challenge") + private String challenge; +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProvider.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProvider.java new file mode 100644 index 00000000..98908172 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProvider.java @@ -0,0 +1,22 @@ +package com.example.keycloak; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.services.resource.RealmResourceProvider; + +public class UserPasskeyProvider implements RealmResourceProvider { + + private final KeycloakSession session; + + public UserPasskeyProvider(KeycloakSession session ) { + this.session = session; + } + + @Override + public Object getResource() { + return new UserPasskeyResource(session); + } + + @Override + public void close() { + } +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProviderFactory.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProviderFactory.java new file mode 100644 index 00000000..d2b12afb --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProviderFactory.java @@ -0,0 +1,31 @@ +package com.example.keycloak; + +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.services.resource.RealmResourceProvider; +import org.keycloak.services.resource.RealmResourceProviderFactory; + +public class UserPasskeyProviderFactory implements RealmResourceProviderFactory { + + public static final String ID = "passkey"; + + @Override + public String getId() { + return ID; + } + + @Override + public RealmResourceProvider create(KeycloakSession session) { + return new UserPasskeyProvider(session); + } + + @Override + public void init(Config.Scope config) {} + + @Override + public void postInit(KeycloakSessionFactory factory) {} + + @Override + public void close() {} +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java new file mode 100644 index 00000000..ce3ff347 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java @@ -0,0 +1,586 @@ +package com.example.keycloak; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.webauthn4j.WebAuthnRegistrationManager; +import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.data.AuthenticationRequest; +import com.webauthn4j.data.RegistrationData; +import com.webauthn4j.data.RegistrationParameters; +import com.webauthn4j.data.RegistrationRequest; +import com.webauthn4j.data.client.Origin; +import com.webauthn4j.data.client.challenge.Challenge; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import com.webauthn4j.server.ServerProperty; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.jboss.logging.Logger; +import org.keycloak.OAuth2Constants; +import org.keycloak.common.util.Base64Url; +import org.keycloak.credential.CredentialModel; +import org.keycloak.credential.CredentialProvider; +import org.keycloak.credential.WebAuthnCredentialModelInput; +import org.keycloak.credential.WebAuthnCredentialProvider; +import org.keycloak.credential.WebAuthnPasswordlessCredentialProviderFactory; +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientSessionContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.credential.WebAuthnCredentialModel; +import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.RefreshToken; +import org.keycloak.services.Urls; +import org.keycloak.services.util.DefaultClientSessionContext; + +import java.lang.reflect.InvocationTargetException; +import java.nio.charset.StandardCharsets; +import java.net.URI; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; + +@Path("/") +public class UserPasskeyResource { + + private final KeycloakSession session; + private static final Logger logger = Logger.getLogger(UserPasskeyResource.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final long CHALLENGE_TTL_MILLIS = 2 * 60 * 1000; + private static final ConcurrentHashMap ISSUED_CHALLENGES = new ConcurrentHashMap<>(); + private static final String CREDENTIAL_USER_ATTR = "passkey-credential-id"; + + /** + * Custom /passkey/* routes do not get Keycloak's OIDC CORS (Web origins). Echo Origin only when it matches. + * Localhost/loopback + staging app host (see module-management client webOrigins). + */ + private static final Pattern ALLOWED_BROWSER_ORIGIN = Pattern.compile( + "^https?://(localhost|127\\.0\\.0\\.1|\\[::1\\])(:\\d+)?$|^https://module\\.aet\\.cit\\.tum\\.de$"); + + @Inject + public UserPasskeyResource(KeycloakSession session) { + this.session = session; + } + + private String getOriginHeader() { + var headers = session.getContext().getRequestHeaders(); + if (headers == null) { + return null; + } + return headers.getHeaderString("Origin"); + } + + private boolean isAllowedOrigin(String origin) { + return origin != null && ALLOWED_BROWSER_ORIGIN.matcher(origin.trim()).matches(); + } + + private Response withCors(Response response) { + String origin = getOriginHeader(); + if (origin == null || !isAllowedOrigin(origin)) { + return response; + } + return Response.fromResponse(response) + .header("Access-Control-Allow-Origin", origin) + .header("Access-Control-Allow-Credentials", "true") + .header("Vary", "Origin") + .build(); + } + + @OPTIONS + @Path("{any:.*}") + public Response corsPreflight() { + Response.ResponseBuilder b = Response.ok() + .header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + .header("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept") + .header("Access-Control-Max-Age", "3600"); + return withCors(b.build()); + } + + @GET + @Path("challenge") + @Produces(MediaType.APPLICATION_JSON) + public Response getChallenge() { + String challengeBase64 = generateChallenge(); + trackChallenge(challengeBase64); + + return withCors(Response.ok("{\"challenge\": \"" + challengeBase64 + "\"}") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .build()); + } + + @GET + @Path("/get-credential-id") + @Produces(MediaType.APPLICATION_JSON) + public Response getCredentialId(@QueryParam("username") String username) { + String challengeBase64 = generateChallenge(); + trackChallenge(challengeBase64); + + if (username == null || username.isBlank()) { + return withCors(Response.ok("{\"challenge\": \"" + challengeBase64 + "\"}") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .build()); + } + + RealmModel realm = session.getContext().getRealm(); + UserModel user = session.users().getUserByUsername(realm, username); + + if (user == null) { + return withCors(Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\": \"User not found\"}") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .build()); + } + + List webAuthnCredentials = user.credentialManager() + .getStoredCredentialsStream() + .filter(cred -> WebAuthnCredentialModel.TYPE_PASSWORDLESS.equals(cred.getType())) + .toList(); + + if (webAuthnCredentials.isEmpty()) { + return withCors(Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\": \"No passkey found for user\"}") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .build()); + } + + WebAuthnCredentialModel credentialModel = WebAuthnCredentialModel.createFromCredentialModel(webAuthnCredentials.get(0)); + String credentialIdBase64 = credentialModel.getWebAuthnCredentialData().getCredentialId(); + String jsonResponse = "{\"credentialId\": \"" + credentialIdBase64 + "\", \"challenge\": \"" + challengeBase64 + "\"}"; + + return withCors(Response.ok(jsonResponse) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .build()); + } + + @POST + @Path("save") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response savePasskey(PasskeyRequest request, @HeaderParam("Authorization") String authorizationHeader) throws JsonProcessingException { + RealmModel realm = session.getContext().getRealm(); + UserModel user = getUserFromBearerToken(realm, authorizationHeader); + if (user == null) { + return withCors(Response.status(Response.Status.UNAUTHORIZED) + .entity("Authenticated user not found from access token") + .header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN) + .build()); + } + + String base64ClientDataJSON = request.getClientDataJSON(); + + byte[] decodedBytes = decodeBase64UrlOrStd(base64ClientDataJSON); + String decodedClientDataJSON = new String(decodedBytes, StandardCharsets.UTF_8); + JsonNode clientData = OBJECT_MAPPER.readTree(decodedClientDataJSON); + + Origin origin = new Origin(clientData.get("origin").asText()); + String rpId = clientData.get("origin").asText().replace("http://", "").replace("https://", "").split(":")[0]; + + Challenge challenge = new DefaultChallenge(clientData.get("challenge").asText()); + + Set originSet = new HashSet<>(); + originSet.add(origin); + ServerProperty serverProperty = new ServerProperty(originSet, rpId, challenge, null); + RegistrationParameters registrationParameters = new RegistrationParameters(serverProperty, true); + + byte[] attestationObject = decodeBase64UrlOrStd(request.getAttestationObject()); + byte[] clientDataJSON = decodeBase64UrlOrStd(request.getClientDataJSON()); + RegistrationRequest registrationRequest = new RegistrationRequest(attestationObject, clientDataJSON); + + WebAuthnRegistrationManager webAuthnRegistrationManager = createWebAuthnRegistrationManager(); + RegistrationData registrationData = webAuthnRegistrationManager.parse(registrationRequest); + validateRegistrationCompat(webAuthnRegistrationManager, registrationRequest, registrationData, registrationParameters); + + WebAuthnCredentialModelInput credential = new WebAuthnCredentialModelInput(WebAuthnCredentialModel.TYPE_PASSWORDLESS); + credential.setAttestedCredentialData(registrationData.getAttestationObject().getAuthenticatorData().getAttestedCredentialData()); + credential.setCount(registrationData.getAttestationObject().getAuthenticatorData().getSignCount()); + credential.setAttestationStatementFormat(registrationData.getAttestationObject().getFormat()); + credential.setTransports(registrationData.getTransports()); + + WebAuthnCredentialProvider webAuthnCredProvider = (WebAuthnCredentialProvider) this.session.getProvider(CredentialProvider.class, WebAuthnPasswordlessCredentialProviderFactory.PROVIDER_ID); + WebAuthnCredentialModel credentialModel = webAuthnCredProvider.getCredentialModelFromCredentialInput(credential, user.getUsername()); + + WebAuthnCredentialModel webAuthnCredentialModel = WebAuthnCredentialModel.createFromCredentialModel(credentialModel); + + user.credentialManager().createStoredCredential(webAuthnCredentialModel); + storeCredentialUserMapping(user, webAuthnCredentialModel.getWebAuthnCredentialData().getCredentialId()); + + return withCors(Response.status(Response.Status.CREATED) + .entity("Passkey stored successfully") + .header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN) + .build()); + } + + private UserModel getUserFromBearerToken(RealmModel realm, String authorizationHeader) { + if (authorizationHeader == null || authorizationHeader.isBlank()) { + return null; + } + if (!authorizationHeader.regionMatches(true, 0, "Bearer ", 0, 7)) { + return null; + } + + String token = authorizationHeader.substring(7).trim(); + if (token.isEmpty()) { + return null; + } + + AccessToken accessToken = session.tokens().decode(token, AccessToken.class); + if (accessToken == null || accessToken.getSubject() == null || accessToken.getSubject().isBlank()) { + return null; + } + + return session.users().getUserById(realm, accessToken.getSubject()); + } + + @POST + @Path("authenticate") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response authenticatePasskey(PasskeyRequest request) throws JsonProcessingException { + RealmModel realm = session.getContext().getRealm(); + + String requestCredentialId = firstNonBlank(request.getCredentialId(), request.getRawId()); + if (requestCredentialId == null) { + return buildErrorResponse(Response.Status.BAD_REQUEST, "credentialId or rawId is required"); + } + + if (!consumeChallenge(request.getChallenge())) { + return buildErrorResponse(Response.Status.UNAUTHORIZED, "Invalid or expired challenge"); + } + + UserModel user = getUserByCredentialId(realm, requestCredentialId); + + if (user == null) + return buildErrorResponse( + Response.Status.NOT_FOUND, + "User not found for credential (username: )" + ); + + WebAuthnCredentialModel webAuthnCredential = getWebAuthnCredential(user, requestCredentialId); + if (webAuthnCredential == null) + return buildErrorResponse(Response.Status.NOT_FOUND, "No passkey found for user: " + user.getUsername()); + + byte[] credentialId = decodeBase64UrlOrStd(requestCredentialId); + byte[] authenticatorData = decodeBase64UrlOrStd(request.getAuthenticatorData()); + byte[] signature = Base64Url.decode(request.getSignature()); + String clientDataJSON = request.getClientDataJSON(); + String challenge = request.getChallenge(); + + boolean isValid = isPasskeyValid(credentialId, authenticatorData, clientDataJSON, signature, challenge, user, realm); + if (isValid) { + return generateTokensResponse(user); + } + return buildErrorResponse(Response.Status.UNAUTHORIZED, "Invalid passkey"); + } + + private UserModel getUserByCredentialId(RealmModel realm, String credentialId) { + String normalizedCredentialId = normalizeCredentialId(credentialId); + if (normalizedCredentialId == null) { + return null; + } + + UserModel mappedUser = session.users() + .searchForUserByUserAttributeStream(realm, CREDENTIAL_USER_ATTR, normalizedCredentialId) + .findFirst() + .orElse(null); + if (mappedUser != null) { + return mappedUser; + } + + int first = 0; + int maxResults = 200; + + while (true) { + List users = getUsersPage(realm, first, maxResults); + + if (users.isEmpty()) { + return null; + } + + UserModel match = users.stream() + .filter(user -> getWebAuthnCredential(user, normalizedCredentialId) != null) + .findFirst() + .orElse(null); + + if (match != null) { + return match; + } + + first += users.size(); + if (users.size() < maxResults) { + return null; + } + } + } + + private List getUsersPage(RealmModel realm, int first, int maxResults) { + List users = session.users() + .searchForUserStream(realm, Map.of("search", ""), first, maxResults) + .toList(); + if (!users.isEmpty()) { + return users; + } + + users = session.users() + .searchForUserStream(realm, Collections.emptyMap(), first, maxResults) + .toList(); + if (!users.isEmpty()) { + return users; + } + + return session.users() + .searchForUserStream(realm, "", first, maxResults) + .toList(); + } + + private WebAuthnCredentialModel getWebAuthnCredential(UserModel user, String credentialId) { + byte[] requestedCredentialId = credentialIdToBytes(credentialId); + if (requestedCredentialId.length == 0) { + return null; + } + + return user.credentialManager() + .getStoredCredentialsByTypeStream(WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .map(WebAuthnCredentialModel::createFromCredentialModel) + .filter(credential -> { + String storedCredentialId = credential.getWebAuthnCredentialData().getCredentialId(); + if (storedCredentialId == null || storedCredentialId.isBlank()) { + return false; + } + + byte[] storedCredentialIdBytes = credentialIdToBytes(storedCredentialId); + return storedCredentialIdBytes.length > 0 && java.util.Arrays.equals(requestedCredentialId, storedCredentialIdBytes); + }) + .findFirst() + .orElse(null); + } + + private boolean isPasskeyValid(byte[] credentialId, byte[] authenticatorData, String clientDataJSON, byte[] signature, String challengeRequest, UserModel user, RealmModel realm) throws JsonProcessingException { + if (challengeRequest == null || challengeRequest.isBlank()) { + return false; + } + + byte[] decodedClientDataBytes = decodeBase64UrlOrStd(clientDataJSON); + String decodedClientDataJSON = new String(decodedClientDataBytes, StandardCharsets.UTF_8); + JsonNode clientData = OBJECT_MAPPER.readTree(decodedClientDataJSON); + + Origin origin = new Origin(clientData.get("origin").asText()); + String rpId = clientData.get("origin").asText().replace("http://", "").replace("https://", "").split(":")[0]; + Challenge challenge = new DefaultChallenge(challengeRequest); + ServerProperty serverProperty = new ServerProperty(origin, rpId, challenge, null); + + String uvPolicy = realm.getWebAuthnPolicyPasswordless().getUserVerificationRequirement(); + boolean isUVFlagChecked = uvPolicy != null && "required".equalsIgnoreCase(uvPolicy); + var authReq = new AuthenticationRequest(credentialId, authenticatorData, decodedClientDataBytes, signature); + var authParams = new WebAuthnCredentialModelInput.KeycloakWebAuthnAuthenticationParameters(serverProperty, isUVFlagChecked); + var cred = new WebAuthnCredentialModelInput(WebAuthnCredentialModel.TYPE_PASSWORDLESS); + + cred.setAuthenticationRequest(authReq); + cred.setAuthenticationParameters(authParams); + return user.credentialManager().isValid(cred); + } + + /** Same idea as standard auth: include openid + default client scopes so aud/roles mappers apply. */ + private String buildScopeParameterForClient(ClientModel client) { + LinkedHashSet scopeNames = new LinkedHashSet<>(); + scopeNames.add(OAuth2Constants.SCOPE_OPENID); + scopeNames.addAll(client.getClientScopes(true).keySet()); + scopeNames.addAll(client.getClientScopes(false).keySet()); + return String.join(" ", scopeNames); + } + + private Response generateTokensResponse(UserModel user) { + try { + RealmModel realm = session.getContext().getRealm(); + + ClientModel client = realm.getClientByClientId("module-management"); + if (client == null) { + logger.error("Client not found for client_id module-management"); + return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Client not found"); + } + session.getContext().setClient(client); + + UserSessionModel userSession = session.sessions().createUserSession( + realm, user, user.getUsername(), "127.0.0.1", "form", true, null, null); + + AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId()); + if (clientSession == null) { + clientSession = session.sessions().createClientSession(realm, client, userSession); + } + + String scopeParameter = buildScopeParameterForClient(client); + ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndScopeParameter( + clientSession, scopeParameter, session); + + if (session.getContext().getClient() == null) { + logger.error("Client context is still null after setting."); + return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Client context is null"); + } + + TokenManager tokenManager = new TokenManager(); + AccessToken accessToken = tokenManager.createClientAccessToken( + session, realm, client, user, userSession, clientSessionCtx); + + var keycloakUri = session.getContext().getUri(); + if (keycloakUri != null && keycloakUri.getBaseUri() != null) { + URI base = keycloakUri.getBaseUri(); + accessToken.issuer(Urls.realmIssuer(base, realm.getName()).toString()); + } else { + logger.warn("Keycloak URI context missing; token will have no iss (resource server will reject)"); + } + + String accessTokenString = session.tokens().encode(accessToken); + RefreshToken refreshToken = new RefreshToken(accessToken); + String refreshTokenString = session.tokens().encode(refreshToken); + return withCors(Response.ok("{\"access_token\": \"" + accessTokenString + "\", \"refresh_token\": \"" + refreshTokenString + "\"}") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .build()); + } catch (Exception e) { + logger.error("Token generation failed: " + e.getMessage(), e); + return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Token generation failed: " + e.getMessage()); + } + } + + private String generateChallenge() { + Challenge challenge = new DefaultChallenge(); + return Base64.getUrlEncoder().withoutPadding().encodeToString(challenge.getValue()); + } + + private Response buildErrorResponse(Response.Status status, String message) { + String safe = message == null ? "" : message.replace("\\", "\\\\").replace("\"", "\\\""); + return withCors(Response.status(status) + .entity("{\"error\": \"" + safe + "\"}") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .build()); + } + + private byte[] decodeBase64UrlOrStd(String base64String) { + if (base64String == null || base64String.isEmpty()) { + return new byte[0]; + } + + String normalized = base64String.replace('-', '+').replace('_', '/'); + int padding = (4 - (normalized.length() % 4)) % 4; + normalized = normalized + "=".repeat(padding); + + return Base64.getDecoder().decode(normalized); + } + + private String normalizeCredentialId(String credentialId) { + if (credentialId == null || credentialId.isBlank()) { + return null; + } + byte[] credentialBytes = credentialIdToBytes(credentialId); + if (credentialBytes.length == 0) { + return null; + } + return Base64.getUrlEncoder().withoutPadding().encodeToString(credentialBytes); + } + + private byte[] credentialIdToBytes(String credentialId) { + if (credentialId == null || credentialId.isBlank()) { + return new byte[0]; + } + + try { + return decodeBase64UrlOrStd(credentialId); + } catch (IllegalArgumentException ignored) { + return new byte[0]; + } + } + + private String firstNonBlank(String primary, String fallback) { + if (primary != null && !primary.isBlank()) { + return primary; + } + if (fallback != null && !fallback.isBlank()) { + return fallback; + } + return null; + } + + private void trackChallenge(String challenge) { + clearExpiredChallenges(); + ISSUED_CHALLENGES.put(challenge, System.currentTimeMillis()); + } + + private boolean consumeChallenge(String challenge) { + if (challenge == null || challenge.isBlank()) { + return false; + } + + clearExpiredChallenges(); + Long issuedAt = ISSUED_CHALLENGES.remove(challenge); + return issuedAt != null && (System.currentTimeMillis() - issuedAt) <= CHALLENGE_TTL_MILLIS; + } + + private void clearExpiredChallenges() { + long now = System.currentTimeMillis(); + ISSUED_CHALLENGES.entrySet().removeIf(entry -> (now - entry.getValue()) > CHALLENGE_TTL_MILLIS); + } + + protected WebAuthnRegistrationManager createWebAuthnRegistrationManager() { + return WebAuthnRegistrationManager.createNonStrictWebAuthnRegistrationManager(new ObjectConverter()); + } + + private void storeCredentialUserMapping(UserModel user, String credentialId) { + String normalizedCredentialId = normalizeCredentialId(credentialId); + if (normalizedCredentialId == null) { + return; + } + + List values = new ArrayList<>(user.getAttributeStream(CREDENTIAL_USER_ATTR).toList()); + if (!values.contains(normalizedCredentialId)) { + values.add(normalizedCredentialId); + user.setAttribute(CREDENTIAL_USER_ATTR, values); + } + } + + private void validateRegistrationCompat(WebAuthnRegistrationManager manager, RegistrationRequest request, RegistrationData data, RegistrationParameters parameters) { + String[] methodNames = {"verify", "validate"}; + Class[][] signatures = { + {RegistrationRequest.class, RegistrationParameters.class}, + {RegistrationData.class, RegistrationParameters.class} + }; + Object[][] args = { + {request, parameters}, + {data, parameters} + }; + + for (String methodName : methodNames) { + for (int i = 0; i < signatures.length; i++) { + try { + manager.getClass() + .getMethod(methodName, signatures[i][0], signatures[i][1]) + .invoke(manager, args[i][0], args[i][1]); + return; + } catch (NoSuchMethodException ignored) { + } catch (IllegalAccessException e) { + throw new RuntimeException("Cannot access WebAuthn registration " + methodName + " method", e); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new RuntimeException("Passkey registration verification failed", cause); + } + } + } + + throw new RuntimeException("Incompatible WebAuthnRegistrationManager methods: neither verify nor validate is supported"); + } + +} diff --git a/keycloak-extension-passkey/src/main/resources/META-INF/beans.xml b/keycloak-extension-passkey/src/main/resources/META-INF/beans.xml new file mode 100644 index 00000000..ded49af4 --- /dev/null +++ b/keycloak-extension-passkey/src/main/resources/META-INF/beans.xml @@ -0,0 +1,4 @@ + + diff --git a/keycloak-extension-passkey/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory b/keycloak-extension-passkey/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory new file mode 100644 index 00000000..285e6994 --- /dev/null +++ b/keycloak-extension-passkey/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory @@ -0,0 +1 @@ +com.example.keycloak.UserPasskeyProviderFactory \ No newline at end of file diff --git a/module-management-realm.json b/module-management-realm.json index 20e30a7b..ccd230cc 100644 --- a/module-management-realm.json +++ b/module-management-realm.json @@ -2353,7 +2353,7 @@ "cibaInterval": "5", "realmReusableOtpCode": "false" }, - "keycloakVersion": "26.0.6", + "keycloakVersion": "26.5.0", "userManagedAccessAllowed": false, "organizationsEnabled": false, "clientProfiles": {